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 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 09:53:54 +01:00
1 parent a5a1b6f9a2
commit d22bfaaaaf
14 files changed
+1089 -71

No files matched your search

+2 -2
View File
@@ -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.':
+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
@@ -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