Merge #806: pick the coding agent per session, with Pi and DeepSeek Harness, and install a missing one

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 20:15:38 +01:00
commit f7fccc5dfc
26 files changed
+2013 -113

No files matched your search

+54 -12
View File
@@ -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.
@@ -57,14 +58,50 @@
* | | | 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, 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
* 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. 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
* figures are filled by the rule of its runner:
@@ -260,6 +297,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();
@@ -621,13 +660,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,
+22 -14
View File
@@ -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
// <apiKey>` 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) }],
},
},
},
+13 -5
View File
@@ -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),
});
}
+280 -29
View File
@@ -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<Record<LocalModelInstallState, Instal
'Stays installed. The runtime is started again unless the person had stopped it, and the digest is read again: a changed digest or a port taken by another program shows as an error on the view and blocks a session, the state stays installed.',
afterCancel: null,
cancel: 'Refused: no job is running.',
actions: ['start-session', 'start', 'stop', 'install', 'use', 'delete-model', 'remove'],
actions: ['start-session', 'start', 'stop', 'install', 'use', 'delete-model', 'install-agent', 'remove'],
},
};
@@ -695,6 +709,13 @@ export interface InstallLiveFacts {
storeBytes?: number;
/** The catalogue's label per model id, for a model the runtime lists and the state file does not know. */
labels?: Readonly<Record<string, string>>;
/**
* 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<Record<string, { detail?: string }>>;
}
/**
* 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<LocalModelCatalog, 'harnesses'>,
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<LocalModelInstallView>;
/**
* 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<LocalModelInstallView>;
/**
* 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<LocalModelVerbResult>;
/**
* `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<LocalModelVerbResult>;
/** `POST /api/local-model/cancel`. Answers once the step in flight was interrupted. */
cancel(): Promise<LocalModelVerbResult>;
/**
@@ -1148,7 +1285,7 @@ export interface LocalModelService {
shutdown(): Promise<void>;
/** 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<void>;
}
@@ -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<void> | 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<void> } | 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<string, { detail?: string }>();
constructor(deps: Partial<LocalModelServiceDeps>) {
const pin = deps.pin ?? OLLAMA_RUNTIME_PIN;
@@ -1730,8 +1874,30 @@ class LocalModelServiceImpl implements LocalModelService {
// ---------- The view ----------
private async liveFacts(state: LocalModelStateFile): Promise<InstallLiveFacts> {
/** 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<InstallLiveFacts> {
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<LocalModelInstallView> {
async view(options: { harnessFacts?: readonly HarnessFact[] } = {}): Promise<LocalModelInstallView> {
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<LocalModelVerbResult> {
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<LocalModelVerbResult> {
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<void> {
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<StepOutcome> {
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<StepOutcome> {
+86 -5
View File
@@ -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;
+22 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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 reLine truncated
**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 reLine truncated
**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)
+14 -2
View File
@@ -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.': '编程智能体会保留,不会被卸载。',
@@ -1628,8 +1640,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.':
+252 -12
View File
@@ -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 <agent>" |
* | | 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,16 @@
* 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 <agent>" 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 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
*
* The window asks the GET once a second WHILE IT IS OPEN, and never otherwise (there is no SSE
@@ -115,6 +128,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 +256,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 +594,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 +634,26 @@ 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 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 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 };
},
/** Which screen the window shows, see the table in @fileoverview. */
_localModelScreen() {
const ui = this._localModelWindowState();
@@ -615,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,
@@ -845,6 +922,28 @@ 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. 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;
this.renderLocalModel();
},
/**
* "Install <agent>": 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 +972,24 @@ 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;
// 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?.();
await this.loadAppSettingsFromServer?.();
await this.loadCliListForSettings?.();
await this.selectCustomModelEntry?.(install.cli, install.endpointId);
await this.selectCustomModelEntry?.(agent, install.endpointId);
},
// ═══════════════════════════════════════════════════════════════
@@ -1495,6 +1598,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 <agent>"), 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 +2166,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 +2186,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 +2217,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),
})
);
}
+88 -1
View File
@@ -23318,6 +23318,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
@@ -23552,7 +23634,8 @@ html[data-skin='rose-pine-dawn'] {
@media (prefers-reduced-motion: reduce) {
.lm-bar-fill,
.lm-model {
.lm-model,
.lm-agent {
transition: none;
}
}
@@ -23563,6 +23646,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;
}
+14 -1
View File
@@ -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<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelAgentSchema, req.body);
return runVerb(reply, () => resolved.service().installAgent(body, actorOf(req)));
});
}
+13 -4
View File
@@ -2495,14 +2495,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),
})
@@ -2525,6 +2531,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