agentus 0.15.4 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,7 +57,7 @@ background process comes back as a turn of its own), an **ACP session that write
57
57
  cockpit-side failure is diagnosable afterwards), and a **DeepSeek web backend** (search +
58
58
  extract on the model key) — come from the fork's integration build instead. That is a five-line install:
59
59
  [§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
60
- `agentus/v0.22.0/merge-20261028-1850`, on Hermes `v0.22.0`.
60
+ `agentus/v0.22.0/merge-20261028-2028`, on Hermes `v0.22.0`.
61
61
 
62
62
  The first slot you start will use the Hermes home **your own agent already uses**
63
63
  (`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
package/README.zh-CN.md CHANGED
@@ -51,7 +51,7 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
51
51
  以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
52
52
  装法就五行:
53
53
  [§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
54
- `agentus/v0.22.0/merge-20261028-1850`,基座 Hermes `v0.22.0`。
54
+ `agentus/v0.22.0/merge-20261028-2028`,基座 Hermes `v0.22.0`。
55
55
 
56
56
  你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
57
57
  指定了另一个 —— 见下面的「HERMES_HOME」。
@@ -60,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
60
60
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
61
61
  last segment sorts, so "newest" is a `sort -V`.
62
62
 
63
- **Current integration build: `agentus/v0.22.0/merge-20261028-1850`** — the newest tag on the fork,
63
+ **Current integration build: `agentus/v0.22.0/merge-20261028-2028`** — the newest tag on the fork,
64
64
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
65
65
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
66
66
  ever looks stale.
@@ -52,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
52
52
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
53
53
  可排序,所以「最新」就是一次 `sort -V`。
54
54
 
55
- **当前集成构建:`agentus/v0.22.0/merge-20261028-1850`** —— fork 上最新的那个 tag,基座 Hermes
55
+ **当前集成构建:`agentus/v0.22.0/merge-20261028-2028`** —— fork 上最新的那个 tag,基座 Hermes
56
56
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
57
57
  以它为准。
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1402,9 +1402,13 @@ export class SessionManager {
1402
1402
  // 就会改到默认 profile 的默认模型上,当前会话那个 profile 反而没变。
1403
1403
  profile: shape.spec.profile ?? null,
1404
1404
  });
1405
- if (!r.ok) console.log(`[agentus] model default not written (${info.backend}): ${r.reason}`);
1405
+ if (!r.ok) {
1406
+ // 唯一一处"操作者需要动手"的失败:set-model 已经把成功回给页面了,这句日志如果不显眼,
1407
+ // 下一次新会话会悄悄退回到旧默认(#72 的症状原样回来)。所以带上 backend 与会话,稳定可 grep。
1408
+ console.warn(`[agentus] modelWriteBack: refused for ${info.id} (${info.backend}, picked ${modelId}): ${r.reason}`);
1409
+ }
1406
1410
  else if (r.changed) {
1407
- console.log(`[agentus] model default → ${modelId}${r.provider ? ` (provider ${r.provider})` : ""} for ${info.backend}`);
1411
+ console.log(`[agentus] model default → ${r.model ?? modelId}${r.provider ? ` (provider ${r.provider})` : ""} for ${info.backend} (picked ${modelId})`);
1408
1412
  }
1409
1413
  } catch (e) {
1410
1414
  console.log(`[agentus] model default write-back failed: ${errMessage(e)}`);
@@ -3092,6 +3096,9 @@ export class SessionManager {
3092
3096
  if (!row) throw new Error(`no such session: ${id}`);
3093
3097
  const known = ((row.models ?? null) as SessionInfo["models"])?.availableModels ?? [];
3094
3098
  if (known.length && !known.some((m) => m.modelId === modelId)) {
3099
+ // 重放的是对话框里的旧值:这一行的清单已经不认它了。不是操作者的错,也不该变成一条红色
3100
+ // 错误:什么都不做,让会话按自己的默认起来(缓存那份由下一次启动的握手刷掉)。
3101
+ if (opts.fromReplay) return this.#coldInfo(id);
3095
3102
  throw new Error(`unknown model: ${modelId}`);
3096
3103
  }
3097
3104
  if (!this.#store.setPendingModel(id, modelId)) throw new Error(`no such session: ${id}`);
@@ -3103,15 +3110,49 @@ export class SessionManager {
3103
3110
  if (!conn || !s.info.acpSessionId) throw new Error("session not ready — resume it first");
3104
3111
  const known = s.info.models?.availableModels ?? [];
3105
3112
  if (known.length && !known.some((m) => m.modelId === modelId)) {
3113
+ if (opts.fromReplay) {
3114
+ // 同上,但活会话有更新的清单在手上:顺手把缓存里的陈旧 id 换掉,下一次对话框就显示真的默认。
3115
+ this.#store.recordHandshakeModels(s.info.backend, {
3116
+ currentModelId: s.info.models?.currentModelId ?? null,
3117
+ available: known.map((m) => m.modelId).filter(Boolean),
3118
+ });
3119
+ return s.info;
3120
+ }
3106
3121
  throw new Error(`unknown model: ${modelId}`);
3107
3122
  }
3108
- await conn.request("session/set_model", { sessionId: s.info.acpSessionId, modelId });
3109
- if (s.info.models) s.info.models = { ...s.info.models, currentModelId: modelId };
3123
+ const reply = await conn.request("session/set_model", { sessionId: s.info.acpSessionId, modelId });
3124
+ // The agent answers with its model state; a few of them (hermes does) re-render the list and mark
3125
+ // the new current there. Ignoring it leaves the slot and the cached handshake on the list from
3126
+ // *before* the switch, which the next start dialog then reads (#72 review). Ids we already know
3127
+ // keep their names/descriptions — the richer shape only exists on the live session.
3128
+ const replied = (reply ?? {}) as {
3129
+ models?: { currentModelId?: string; availableModels?: { modelId?: string }[] } | null;
3130
+ };
3131
+ const returnedIds = (replied.models?.availableModels ?? [])
3132
+ .map((m) => String(m.modelId ?? "")).filter(Boolean);
3133
+ if (s.info.models) {
3134
+ const known = s.info.models.availableModels ?? [];
3135
+ const merged = [...known];
3136
+ for (const id of returnedIds) {
3137
+ if (!merged.some((m) => m.modelId === id)) merged.push({ modelId: id, name: id, description: null });
3138
+ }
3139
+ s.info.models = { ...s.info.models, currentModelId: replied.models?.currentModelId ?? modelId, availableModels: merged };
3140
+ }
3141
+ // #72:缓存握手是"上一次真启动"的快照,新建会话对话框只读它 —— 换了模型却不改它,对话框会一直
3142
+ // 显示(并且把)切换前的那个 id 当中文默认值。活会话手里有完整清单,顺手把两份都刷新。
3143
+ // 以**活会话**为准:agent 可能在回复里规范化了 id(或表示"没变"),那时缓存的默认值必须跟它走,
3144
+ // 否则对话框显示的、重放的,都是槽位没在跑的那个 id(MINOR 4)。
3145
+ this.#store.recordHandshakeModels(s.info.backend, {
3146
+ currentModelId: s.info.models?.currentModelId ?? modelId,
3147
+ available: (s.info.models?.availableModels ?? []).map((m) => m.modelId).filter(Boolean),
3148
+ });
3110
3149
  if (s.info.pendingModelId) s.info.pendingModelId = null;
3111
3150
  this.#store.setPendingModel(id, null);
3112
3151
  // #62-1: 只有 agent **接受**过的切换才计数 —— 被拒的条目永不加分,于是"广告了但实际不可用"
3113
3152
  // 的模型会自己沉到列表底部,不需要任何端点探活。
3114
- bumpModelUsage(`${s.info.backend}|${modelId}`);
3153
+ // 重放(新建会话时把对话框里的选择下发)不是操作者的切换动作:既不该改写全局默认,也不该给
3154
+ // 「常用模型」加分,否则排序量的是"起了多少会话",不是"选了哪个模型"(#72 review MINOR 6)。
3155
+ if (!opts.fromReplay) bumpModelUsage(`${s.info.backend}|${modelId}`);
3115
3156
  // #62-3:选定即成为 agent 的全局默认(设置里可关)。同样只在这一行之后 —— 被拒的模型不落盘。
3116
3157
  // **重放不算**:冷槽上留下的意图可能被一次无人值守的 completion 唤醒带上(resume <-
3117
3158
  // `this.resume(e.origin)`),那时代码里没有"操作者在选模型"这件事,不该改全局配置。
@@ -2159,7 +2159,7 @@ wss.on("connection", (ws) => {
2159
2159
  await mgr.setConfig(cmd.sessionId, cmd.configId, cmd.value);
2160
2160
  break;
2161
2161
  case "set-model":
2162
- await mgr.setModel(cmd.sessionId, cmd.modelId);
2162
+ await mgr.setModel(cmd.sessionId, cmd.modelId, { fromReplay: cmd.fromReplay === true });
2163
2163
  break;
2164
2164
  case "respond-permission": {
2165
2165
  const applied = mgr.respondPermission(cmd.sessionId, cmd.requestId, cmd.decision, {
@@ -8,7 +8,14 @@
8
8
  // · 只在 server 的 setModel **成功之后**、且是操作者动作时调用(重放不算,见 session-manager)。
9
9
  // · 写完必须能读回来:写盘前用真 YAML 解析器验一遍 `model.default` 是不是那个值,验不过就**拒写**。
10
10
  // 这是别人的全局配置文件,写坏了下次 spawn 直接起不来 —— 宁可不动,也不能"改成功但改坏"。
11
- // · 原子写(临时文件 + rename)并保持原权限;只动 `model.default` / `model.provider` 两行。
11
+ // · 原子写(临时文件 + rename)并保持原权限。
12
+ // · 写进去的必须是 hermes 的**配置形状**,不是 ACP 的线格式:`default: <裸模型名>` +
13
+ // `provider: custom:<条目名>`。把选择器的三段 id(`custom:<条目>:<模型>`)直接塞进
14
+ // `model.default`,新建会话会把它整体当模型名发出去(`404 model_not_found`),或者被按第一个
15
+ // 冒号切成 `provider=custom` + 剩下的整串 —— 静默跑到别的端点上(#72)。
16
+ // · 端点跟着条目走:命名条目自己声明 api_url/api_mode,块里残留的是**上一个** provider 的口子
17
+ // (`base_url` 指向旧云端点、`api_mode: codex_responses`),换 provider 时必须清掉,
18
+ // 否则它会盖掉条目的声明。
12
19
  import { chmodSync, copyFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
13
20
  import path from "node:path";
14
21
  import { parse as parseYaml } from "yaml";
@@ -18,6 +25,8 @@ export interface WriteBackResult {
18
25
  changed: boolean;
19
26
  /** 写进去的 provider;null = 没写(推不出或不安全) */
20
27
  provider: string | null;
28
+ /** 写进 `model.default` 的裸模型名(从 ACP choice id 拆出来的那个) */
29
+ model?: string;
21
30
  reason?: string;
22
31
  }
23
32
 
@@ -109,6 +118,226 @@ function setKey(lines: string[], key: string, value: string, indent: string): st
109
118
  return copy;
110
119
  }
111
120
 
121
+
122
+ /** 命名端点(`providers:` 的键 / `custom_providers:` 的 name)在配置里声明的端点与协议。
123
+ *
124
+ * 写回时这两样是**端点归属**的唯一来源:provider 认条目,端口跟着条目走。块级的
125
+ * `base_url` / `api_mode` 是上一个 provider 留下的,换 provider 时要清掉(#72)。 */
126
+ export interface EndpointInfo {
127
+ /** 持久身份:`custom:<config 键>`(legacy 条目用规范化后的 name) */
128
+ id: string;
129
+ /** 条目自己声明的端点;`null` = 这个块没有端点(见 `namedEndpoints` 为什么跳过它) */
130
+ apiUrl: string | null;
131
+ apiMode: string | null;
132
+ /** 条目声明的模型 id;`models:` 没写就是空数组(靠发现拿模型的那种条目) */
133
+ models: string[];
134
+ }
135
+
136
+ /** 一张表两个命名空间:**键**是身份,**显示名**只是弱别名。 */
137
+ export interface NamedEndpoints {
138
+ byKey: Map<string, EndpointInfo>;
139
+ byAlias: Map<string, EndpointInfo>;
140
+ }
141
+
142
+ /** hermes 的 `custom_provider_slug`:小写、空格换连字符、补 `custom:` 前缀。 */
143
+ const customSlug = (display: string): string => {
144
+ const normalized = display.trim().toLowerCase().replace(/ /g, "-");
145
+ return normalized.startsWith("custom:") ? normalized : `custom:${normalized}`;
146
+ };
147
+
148
+ const bareName = (id: string): string => id.trim().toLowerCase().replace(/^custom:/, "");
149
+
150
+ /** hermes 的 `is_provider_enabled`:假值一律视为停用(非字符串非布尔的容器值按 truthiness)。 */
151
+ const isDisabled = (entry: Record<string, unknown>): boolean => {
152
+ const raw = entry.enabled;
153
+ if (raw === undefined || raw === null) return false;
154
+ if (typeof raw === "boolean") return !raw;
155
+ if (typeof raw === "string") {
156
+ return ["false", "no", "off", "0", "disabled"].includes(raw.trim().toLowerCase());
157
+ }
158
+ return !raw;
159
+ };
160
+
161
+ /** 条目声明的模型 id(`models:` 里元素可以是字符串或 `{id|model|name}`)。 */
162
+ const declaredModels = (entry: Record<string, unknown>): string[] => {
163
+ const raw = entry.models;
164
+ if (!Array.isArray(raw)) return [];
165
+ const out: string[] = [];
166
+ for (const item of raw) {
167
+ const value = typeof item === "string" ? item
168
+ : item && typeof item === "object" ? ((item as Record<string, unknown>).id
169
+ ?? (item as Record<string, unknown>).model ?? (item as Record<string, unknown>).name) : null;
170
+ const text = typeof value === "string" ? value.trim() : "";
171
+ if (text) out.push(text);
172
+ }
173
+ return out;
174
+ };
175
+
176
+ /** 配置里**真的能路由**的命名端点:键是持久身份 `custom:<条目>`,显示名另作弱别名。
177
+ *
178
+ * 三条判据都是 hermes 自己的:
179
+ *
180
+ * 1. **没有端点的块不是命名端点。** `providers.bedrock: {stale_timeout_seconds: 600}` 是给内置
181
+ * provider 调参的,`_pick_provider_base_url` 为空的条目会被 `get_compatible_custom_providers`
182
+ * 丢掉、`resolve_user_provider` 也不认 —— 把它当端点写下去,`custom:bedrock` 是个谁都不认识的
183
+ * 身份,还会顺手删掉块里唯一能路由的 `base_url`。所以这种块整条跳过。
184
+ * 2. **停用条目不存在**(`enabled: false`):写进 `model.provider` 等于让下一次 spawn 起不来。
185
+ * 3. **别名永不覆盖键。** 条目 A 的 `name:` 可能与条目 B 的键同名;键是身份、别名只是「描述里印的
186
+ * 是哪个名字」,所以别名只在键查不到时用,且先到先得(hermes 的解析也是取第一个匹配)。
187
+ *
188
+ * 端点字段顺序照 hermes 的 `_pick_provider_base_url`:`base_url` → `url` → `api`。
189
+ * `api_url` 是 inventory 行(运行时)的字段,不是 config 键;只作最后容错。 */
190
+ export function namedEndpoints(doc: unknown): NamedEndpoints {
191
+ const byKey = new Map<string, EndpointInfo>();
192
+ const byAlias = new Map<string, EndpointInfo>();
193
+ const endpointOf = (entry: Record<string, unknown>): string | null => {
194
+ for (const key of ["base_url", "url", "api", "api_url"]) {
195
+ const value = entry[key];
196
+ if (typeof value === "string" && value.trim()) return value.trim();
197
+ }
198
+ return null;
199
+ };
200
+ const add = (id: string, entry: Record<string, unknown>): void => {
201
+ const apiUrl = endpointOf(entry);
202
+ if (!apiUrl) return; // 见上:调参块不是端点
203
+ const info: EndpointInfo = {
204
+ id, apiUrl,
205
+ apiMode: typeof entry.api_mode === "string" ? entry.api_mode : null,
206
+ models: declaredModels(entry),
207
+ };
208
+ // 同一个键写两遍时后者覆盖(YAML 本身也不允许),但别名绝不覆盖任何已存在的**键**。
209
+ byKey.set(id, info);
210
+ const display = typeof entry.name === "string" ? entry.name.trim() : "";
211
+ if (display) {
212
+ const alias = customSlug(display);
213
+ if (!byKey.has(alias) && !byAlias.has(alias)) byAlias.set(alias, info);
214
+ }
215
+ };
216
+ const d = (doc ?? {}) as { providers?: unknown; custom_providers?: unknown };
217
+ const providers = d.providers;
218
+ if (providers && typeof providers === "object" && !Array.isArray(providers)) {
219
+ for (const [key, entry] of Object.entries(providers as Record<string, unknown>)) {
220
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
221
+ const raw = entry as Record<string, unknown>;
222
+ if (isDisabled(raw)) continue;
223
+ // 键是身份(显示名可以改),和 hermes 的 custom_provider_slug(display, key) 同一个取向。
224
+ add(customSlug(key), raw);
225
+ }
226
+ }
227
+ const legacy = d.custom_providers;
228
+ if (Array.isArray(legacy)) {
229
+ for (const entry of legacy) {
230
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
231
+ const raw = entry as Record<string, unknown>;
232
+ if (isDisabled(raw)) continue;
233
+ const name = typeof raw.name === "string" ? raw.name.trim() : "";
234
+ if (!name) continue;
235
+ add(customSlug(name), raw);
236
+ }
237
+ }
238
+ return { byKey, byAlias };
239
+ }
240
+
241
+ export interface ChoiceSplit {
242
+ /** hermes `model.default` 该写的值:裸模型名 */
243
+ model: string;
244
+ /** hermes `model.provider` 该写的值;null = 推不出来,**保持原样**(不猜) */
245
+ provider: string | null;
246
+ /** true = 这个 id 描述了一个本配置里没有的条目,且连 provider 都定不下来:不许写 */
247
+ unresolved?: boolean;
248
+ }
249
+
250
+ /** 把选择器的 choice id 拆回 `(裸模型名, 持久 provider 身份)`。
251
+ *
252
+ * 规则就是 hermes 自己的那条:provider 只在左半段是**已知 provider/条目**时才算分隔符,
253
+ * 否则整串都是模型名(`anthropic/claude-3.5-sonnet:beta` 不能被切)。
254
+ *
255
+ * 描述(`Provider: <名字>`)只当**弱**信号用,这是这里最容易错的地方:
256
+ * - 它印的是条目的**显示名**,可能与另一个条目/内置 provider 重名,所以只有「条目自己声明了这个
257
+ * 模型」时它才算证据(否则 `OpenRouter` 这个显示名会把内置 openrouter 的 pick 拽到本地代理上);
258
+ * - 带前缀的 id(`<左段>:<模型>`)里左段不是键时一律按 canonical/provider 读,不看描述 —— 行自己
259
+ * 带来的前缀比描述更权威;
260
+ * - 什么都推不出来时返回 `provider: null`(调用方保持 `model.provider` 原样),不写一个不存在的
261
+ * slug —— 那会让下一次 spawn 拿到一个解析不了的 provider。 */
262
+ export function splitChoiceId(
263
+ modelId: string,
264
+ providerHint: string | null,
265
+ named: NamedEndpoints,
266
+ ): ChoiceSplit {
267
+ const raw = (modelId ?? "").trim();
268
+ if (!raw) return { model: raw, provider: null };
269
+ const lowered = raw.toLowerCase();
270
+ const hintSlug = providerHint
271
+ ? (providerHint.includes(":") ? providerHint.trim().toLowerCase() : customSlug(providerHint))
272
+ : null;
273
+ const keyInfo = hintSlug ? named.byKey.get(hintSlug) : undefined;
274
+ const aliasInfo = hintSlug && !keyInfo ? named.byAlias.get(hintSlug) : undefined;
275
+ /** 描述指向的条目身份(键优先,别名次之)。只在 `custom:` 前缀已经把「这是条目」说清的地方用。 */
276
+ const hintEntryId = keyInfo?.id ?? aliasInfo?.id ?? null;
277
+ /** 描述本身长得像一个 provider slug(而不是一句显示名):`Alibaba` / `kimi-coding`。 */
278
+ const hintIsSlug = Boolean(hintSlug && /^[a-z0-9][a-z0-9._-]*$/.test(hintSlug.replace(/^custom:/, "")));
279
+ const declares = (info: EndpointInfo | undefined, model: string): boolean =>
280
+ Boolean(info && model && info.models.some((m) => m.toLowerCase() === model.toLowerCase()));
281
+
282
+ // 1) `custom:<…>` —— 前缀自己说明「这是个 custom 条目」,所以这里别名也算数(F5:显示名 → 键)。
283
+ if (lowered.startsWith("custom:")) {
284
+ const rest = raw.slice("custom:".length);
285
+ const cut = rest.indexOf(":");
286
+ if (cut > 0) {
287
+ const head = customSlug(rest.slice(0, cut));
288
+ const entry = named.byKey.get(head) ?? named.byAlias.get(head);
289
+ if (entry) return { model: rest.slice(cut + 1).trim(), provider: entry.id };
290
+ // 第二段不是条目,两种读法都说得通,只能让条目自己的声明白话:
291
+ // · 条目**声明了** `qwen3:32b` 这种带冒号的模型 → 旧形状(模型名是整段)
292
+ // · 条目声明了后半段 → 条目前缀是陈旧的(条目被改名/删掉过,对话框还是旧快照)
293
+ // 两种都对上(或都没声明)才是真歧义:那时不写,让调用方看到原因。
294
+ if (keyInfo && declares(keyInfo, rest)) return { model: rest.trim(), provider: keyInfo.id };
295
+ if (keyInfo && declares(keyInfo, rest.slice(cut + 1).trim())) {
296
+ return { model: rest.slice(cut + 1).trim(), provider: keyInfo.id };
297
+ }
298
+ // 真歧义:条目既没声明整段、也没声明后半段。写哪个都得赌一次,所以不写(reason 会带上)。
299
+ return { model: raw, provider: null, unresolved: true };
300
+ }
301
+ // `custom:<模型>`:模型名是确定的那半,provider 只能靠描述推。
302
+ return { model: rest.trim(), provider: hintEntryId };
303
+ }
304
+
305
+ // 2) `<左段>:<模型>`:左段是配置键 → 身份权威;否则整段就是模型名的一部分被行前缀带着,
306
+ // 按 provider/canonical slug 原样写(`openrouter:claude` 绝不能因为显示名重名改成 custom:x)。
307
+ const cut = raw.indexOf(":");
308
+ if (cut > 0) {
309
+ const left = raw.slice(0, cut).trim().toLowerCase();
310
+ const tail = raw.slice(cut + 1).trim();
311
+ const entry = named.byKey.get(customSlug(left));
312
+ if (entry) return { model: tail, provider: entry.id };
313
+ // 左段与描述指的是同一个名字,且那个名字是个 provider slug ⇒ 左段就是 provider(`alibaba:x`)。
314
+ if (hintIsSlug && bareName(customSlug(left)) === bareName(hintSlug ?? "")) {
315
+ // 只有**键**能当身份:别名说「描述里印的是这个名字」,而 cloud 与本地代理重名时
316
+ // 正是靠这一条才不会把云 provider 的 pick 写成本地条目(MAJOR 3)。
317
+ return { model: tail, provider: keyInfo?.id ?? left };
318
+ }
319
+ // 其余情况:左段不是已知条目/描述里的名字,那它可能只是模型名的一部分
320
+ // (`anthropic/claude-3.5-sonnet:beta` 的冒号在斜杠后面)。整串留着,不猜。
321
+ return { model: raw, provider: null };
322
+ }
323
+
324
+ // 3) 裸模型名:键就是证据;显示名要再对上「条目声明了这个模型」才算(重名时不敢认)。
325
+ if (keyInfo) return { model: raw, provider: keyInfo.id };
326
+ if (aliasInfo && declares(aliasInfo, raw)) return { model: raw, provider: aliasInfo.id };
327
+ // 描述是个 slug 形状(`Alibaba`/`kimi-coding`)⇒ canonical provider,写下去 hermes 认得。
328
+ // 一句显示名(`My Lab Box`)推不出任何身份,保持 `model.provider` 原样。
329
+ if (hintIsSlug && hintSlug) return { model: raw, provider: bareName(hintSlug) };
330
+ return { model: raw, provider: null };
331
+ }
332
+
333
+ function deleteKey(lines: string[], key: string): string[] {
334
+ const at = findInModelBlock(lines, key);
335
+ if (at < 0) return lines;
336
+ const copy = [...lines];
337
+ copy.splice(at, 1);
338
+ return copy;
339
+ }
340
+
112
341
  /** 把 modelId(以及可安全推断时的 provider)写进 agent 的配置文件。 */
113
342
  export function writeModelDefault(
114
343
  backend: string,
@@ -139,28 +368,74 @@ export function writeModelDefault(
139
368
  return { ok: false, changed: false, provider: null, reason: `model: is not a plain block (${header.trim().slice(0, 40)})` };
140
369
  }
141
370
 
142
- const provider = options.provider ? String(options.provider).trim() : null;
371
+ // 配置里有哪些命名端点,决定了 choice id 怎么拆、以及块内残留的端点要不要清。
372
+ let before: unknown = null;
373
+ try { before = parseYaml(text); } catch { before = null; }
374
+ const named = namedEndpoints(before);
375
+ const split = splitChoiceId(modelId, options.provider ?? null, named);
376
+ const provider = split.provider;
377
+ const desiredModel = split.model;
378
+ if (split.unresolved) {
379
+ // 对话框拿的是旧快照时会出现:条目已被改名/删除,pick 却还带着 `custom:<旧名>:<模型>`。
380
+ // 把它当 model.default 写下去,等于把 #72 的病灶重新种进配置(模型名带条目前缀、或按第一个冒号
381
+ // 切成 provider=custom),所以整次拒写,让调用方看到原因。
382
+ return {
383
+ ok: false, changed: false, provider, model: desiredModel,
384
+ reason: `refusing to write: ${modelId} names an endpoint this config does not declare`,
385
+ };
386
+ }
387
+ const previousProvider = (before as { model?: { provider?: unknown } } | null)?.model?.provider;
388
+ const previous = typeof previousProvider === "string" ? previousProvider.trim() : null;
389
+ const providerChanged = provider != null && provider !== previous;
390
+ // 端点归属问的是「这个条目**声明了**端点吗」,不是「有这个条目吗」:没有端点的块是给内置
391
+ // provider 调参用的(见 namedEndpoints),它既不拥有块里的 base_url,也当不了 model.provider。
392
+ const entry = provider ? named.byKey.get(provider) : undefined;
393
+ // 块级的 base_url/api_mode 是**上一个 provider 的口子**,两种情况下都必然过期或多余:
394
+ // · 换了 provider —— 它描述的是被换掉的那一个;
395
+ // · 新 provider 是命名条目 —— 端点归条目所有(条目改了端口/协议,块里那份就成了矛盾状态)。
396
+ // 只按「换 provider 且端点归某个条目」判断,会漏掉 `custom → openrouter`(新 provider 不声明端点),
397
+ // 于是写出「provider 是云厂商、base_url 还指着本地边车」的块,那正是 #72 B 段的错路由。
398
+ const dropEndpointKeys = providerChanged || Boolean(entry?.apiUrl);
399
+
143
400
  const indent = blockIndent(lines);
144
401
  let work = lines;
145
402
  if (provider) work = setKey(work, "provider", provider, indent);
146
- work = setKey(work, "default", modelId, indent);
403
+ work = setKey(work, "default", desiredModel, indent);
404
+ if (dropEndpointKeys) {
405
+ work = deleteKey(work, "base_url");
406
+ work = deleteKey(work, "api_mode");
407
+ }
147
408
 
148
409
  const next = work.join("\n");
149
- if (next === text) return { ok: true, changed: false, provider };
410
+ if (next === text) return { ok: true, changed: false, provider, model: desiredModel };
150
411
 
151
- // 写盘前的最后一道闸:真解析一遍,两个值都得读回来。任何一个不成立就一个字都不写。
412
+ // 写盘前的最后一道闸:真解析一遍。不只看"两行读得回",要看**这个块自洽**——
413
+ // provider 指向的条目和块里的端点必须是同一个端点,否则整次拒写(旧闸门会稳定放过矛盾状态)。
414
+ // 常规路径上端点键已经在上面被删掉了,所以这里主要拦 `deleteKey` 正则认不出的写法
415
+ // (`base_url :` 中间带空格之类)—— 门槛低但便宜,且它是唯一一处验证「磁盘上的块」而不是
416
+ // 「我打算写的块」的地方。
152
417
  let doc: unknown;
153
418
  try {
154
419
  doc = parseYaml(next);
155
420
  } catch (e) {
156
- return { ok: false, changed: false, provider, reason: `refusing to write: result would not parse (${(e as Error).message.split("\n")[0]})` };
421
+ return { ok: false, changed: false, provider, model: desiredModel, reason: `refusing to write: result would not parse (${(e as Error).message.split("\n")[0]})` };
157
422
  }
158
- const model = (doc as { model?: { default?: unknown; provider?: unknown } } | null)?.model;
159
- if (!model || model.default !== modelId) {
160
- return { ok: false, changed: false, provider, reason: "refusing to write: model.default would not read back" };
423
+ const model = (doc as { model?: { default?: unknown; provider?: unknown; base_url?: unknown; api_mode?: unknown } } | null)?.model;
424
+ if (!model || model.default !== desiredModel) {
425
+ return { ok: false, changed: false, provider, model: desiredModel, reason: "refusing to write: model.default would not read back" };
161
426
  }
162
427
  if (provider && model.provider !== provider) {
163
- return { ok: false, changed: false, provider, reason: "refusing to write: model.provider would not read back" };
428
+ return { ok: false, changed: false, provider, model: desiredModel, reason: "refusing to write: model.provider would not read back" };
429
+ }
430
+ if (entry?.apiUrl) {
431
+ const norm = (v: unknown) => String(v ?? "").trim().replace(/\/+$/, "").toLowerCase();
432
+ const blockUrl = norm(model.base_url);
433
+ if (blockUrl && blockUrl !== norm(entry.apiUrl)) {
434
+ return { ok: false, changed: false, provider, model: desiredModel, reason: `refusing to write: model.base_url (${String(model.base_url)}) is not where ${provider} lives (${entry.apiUrl})` };
435
+ }
436
+ if (model.api_mode && entry.apiMode && String(model.api_mode) !== entry.apiMode) {
437
+ return { ok: false, changed: false, provider, model: desiredModel, reason: `refusing to write: model.api_mode (${String(model.api_mode)}) contradicts ${provider} (${entry.apiMode})` };
438
+ }
164
439
  }
165
440
 
166
441
  // 配置里可能有 api_key:比 0600 宽的一律收紧。
@@ -173,7 +448,7 @@ export function writeModelDefault(
173
448
  renameSync(tmp, file);
174
449
  } catch (e) {
175
450
  try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
176
- return { ok: false, changed: false, provider, reason: `write failed: ${(e as Error).message}` };
451
+ return { ok: false, changed: false, provider, model: desiredModel, reason: `write failed: ${(e as Error).message}` };
177
452
  }
178
- return { ok: true, changed: true, provider };
453
+ return { ok: true, changed: true, provider, model: desiredModel };
179
454
  }
@@ -1786,6 +1786,46 @@ export class Store {
1786
1786
  return Number(info.changes ?? 0) > 0;
1787
1787
  }
1788
1788
 
1789
+ /**
1790
+ * Refresh the MODEL half of a row's cached handshake.
1791
+ *
1792
+ * The handshake is a snapshot of the last real start, and it is the only thing a start dialog can
1793
+ * read for a backend that is not running: a `currentModelId` captured before the operator switched
1794
+ * models is stale forever (nothing else rewrites it), and the dialog would present — and replay —
1795
+ * the model the agent no longer uses (#72). ``available`` is optional: only the live session knows
1796
+ * the full list, so pass it when the caller has one.
1797
+ */
1798
+ recordHandshakeModels(
1799
+ id: string,
1800
+ models: { currentModelId?: string | null; available?: string[] },
1801
+ ): void {
1802
+ const row = this.#db.prepare("select handshake from backends where id = ?").get(id) as
1803
+ | { handshake?: string | null }
1804
+ | undefined;
1805
+ if (!row?.handshake) return; // a row that never ran has no snapshot to correct
1806
+ let hs: BackendHandshake;
1807
+ try {
1808
+ hs = JSON.parse(row.handshake) as BackendHandshake;
1809
+ } catch {
1810
+ return;
1811
+ }
1812
+ if (!hs?.models) return;
1813
+ const current = models.currentModelId ?? null;
1814
+ const supplied = models.available?.length ? models.available : hs.models.available;
1815
+ // `current` must stay inside `available`: the dialog only shows an id that is in the list
1816
+ // (`startModelValue`), so caching a current the list cannot contain would silently downgrade the
1817
+ // next new-session default to "agent default" right after the operator switched (#72 review).
1818
+ // …but it must not GROW the cache either: `available` is the new-session dialog's model list, so a
1819
+ // prepend per switch would accrete ids of endpoints/models that no longer exist, each one a
1820
+ // selectable row that answers "unknown model". Keep the list's length and let the oldest entry
1821
+ // fall off — the next real agent start refreshes the whole snapshot anyway (#72 review).
1822
+ const available = current && !supplied.includes(current)
1823
+ ? [current, ...supplied.filter((m) => m !== current)].slice(0, Math.max(supplied.length, 1))
1824
+ : supplied;
1825
+ hs.models = { ...hs.models, currentModelId: current, available };
1826
+ this.#db.prepare("update backends set handshake = ? where id = ?").run(JSON.stringify(hs), id);
1827
+ }
1828
+
1789
1829
  /**
1790
1830
  * Write back a health snapshot (and optionally a handshake) for one row. Deliberately a
1791
1831
  * column-scoped UPDATE rather than upsertBackend: a check must never resurrect a stale copy of
@@ -502,7 +502,9 @@ export type ClientCommand =
502
502
  | { t: "cancel"; sessionId: string }
503
503
  | { t: "set-mode"; sessionId: string; modeId: string }
504
504
  | { t: "set-config"; sessionId: string; configId: string; value: string | number | boolean }
505
- | { t: "set-model"; sessionId: string; modelId: string }
505
+ /** `fromReplay` = 这条帧是在重放对话框里的选择(新会话建立时那一轮),不是操作者刚点的切换:
506
+ * 写回全局默认与用量计数都只认后者(见 session-manager 的 `#writeBackModelDefault`/`bumpModelUsage`)。 */
507
+ | { t: "set-model"; sessionId: string; modelId: string; fromReplay?: boolean }
506
508
  | {
507
509
  t: "respond-permission";
508
510
  sessionId: string;