agentus 0.13.1 → 0.14.1

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-20261023-1851`, on Hermes `v0.22.0`.
60
+ `agentus/v0.22.0/merge-20261026-1237`, 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-20261023-1851`,基座 Hermes `v0.22.0`。
54
+ `agentus/v0.22.0/merge-20261026-1237`,基座 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-20261023-1851`** — the newest tag on the fork,
63
+ **Current integration build: `agentus/v0.22.0/merge-20261026-1237`** — 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-20261023-1851`** —— fork 上最新的那个 tag,基座 Hermes
55
+ **当前集成构建:`agentus/v0.22.0/merge-20261026-1237`** —— 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.13.1",
3
+ "version": "0.14.1",
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": {
@@ -617,6 +617,39 @@ const agent = () => ({
617
617
  return { stopReason: "end_turn" };
618
618
  }
619
619
 
620
+ // `[tail-probe]` — 一次「说一半就去做工具」的回合,用来钉住朗读队列的那条规矩(#60):
621
+ // 第一段文字以**没有句末标点的半句**结尾(朗读器会先扣着等合并),紧接着调一个工具(回合
622
+ // 因此不结束),然后**另一条消息**接着说。真实后端就是这个形状,而扣着的半句曾经在这里蒸发。
623
+ // QA only:别的提示词完全不走这条路。
624
+ if (/\[tail-probe\]/.test(text)) {
625
+ const first = `第一段:我先把结论说一遍。然后我去看文件,最后半句没有句号`;
626
+ await send(agent._conn, sessionId, {
627
+ sessionUpdate: "agent_message_chunk",
628
+ messageId: `tail-a-${sessionId}`,
629
+ content: { type: "text", text: first },
630
+ });
631
+ await sleep(60);
632
+ const id = `tc-${sessionId}-tail`;
633
+ await send(agent._conn, sessionId, {
634
+ sessionUpdate: "tool_call", toolCallId: id,
635
+ title: "read_file docs/notes.md", kind: "read", status: "in_progress",
636
+ rawInput: { path: "docs/notes.md" },
637
+ });
638
+ await sleep(400);
639
+ await send(agent._conn, sessionId, {
640
+ sessionUpdate: "tool_call_update", toolCallId: id,
641
+ status: "completed", rawOutput: "42 lines",
642
+ });
643
+ await sleep(120);
644
+ await send(agent._conn, sessionId, {
645
+ sessionUpdate: "agent_message_chunk",
646
+ messageId: `tail-b-${sessionId}`,
647
+ content: { type: "text", text: "文件看完了,这里是第二段。" },
648
+ });
649
+ await sleep(400); // let the frame land before the turn closes (same as the other fixtures)
650
+ return { stopReason: "end_turn" };
651
+ }
652
+
620
653
  if (wantThink) {
621
654
  // ONE `messageId` per burst, like the real adapter sends: ACP gives every chunk of one
622
655
  // thought block the same id, and the cockpit/server coalesce on it (`block_key`). A mock
@@ -15,6 +15,7 @@ import type {
15
15
  RequestPermissionResponse,
16
16
  } from "@agentclientprotocol/sdk";
17
17
  import { applyContextWindow, BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
18
+ import { providerFromDescription, writeModelDefault } from "../model-default.js";
18
19
  import { forgetAgentOwner, recordAgentOwner, verifyAgentOwner } from "./agent-owner.js";
19
20
  import { thinkingOptionOf, voiceAppliesToPrompt, voiceModeBlock } from "./voice-mode.js";
20
21
  import {
@@ -51,7 +52,7 @@ import type {
51
52
  } from "../../../shared/src/index.js";
52
53
  import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, clampTitle, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
53
54
  import { PLAN_GUIDANCE_ACTIVE, PLAN_GUIDANCE_INTRO, PLAN_GUIDANCE_NATIVE } from "../../../shared/src/index.js";
54
- import { AGENT_DEFAULT, PREFS_DEFAULT, agentSettings, type PlanHintSettings } from "../settings.js";
55
+ import { AGENT_DEFAULT, PREFS_DEFAULT, agentSettings, bumpModelUsage, type PlanHintSettings } from "../settings.js";
55
56
 
56
57
  // The cockpit CLI an agent is handed (issue #15). Resolved from THIS module's location, so it points
57
58
  // at the same tree the server runs from — a checkout and an installed package alike (`bin/` is in the
@@ -964,6 +965,9 @@ export class SessionManager {
964
965
  // this method — that is exactly what keeps a per-session pick from being silently undone.
965
966
  await this.#applyDefaultMode(live);
966
967
  this.#updateSession(live);
968
+ // #62-2:新建会话从 `starting` 起步,握手这 1–3 秒里操作者已经能在 rail 上点它并选模型
969
+ // (那时走冷分支写进 pending)。没有这一句,那个选择要等到几小时后被回收再 resume 才生效。
970
+ await this.#replayPendingModel(live.info.id);
967
971
  } catch (err) {
968
972
  child.kill("SIGKILL");
969
973
  live.info.status = "error";
@@ -1213,7 +1217,12 @@ export class SessionManager {
1213
1217
  }
1214
1218
  }
1215
1219
  live.info.status = "ready";
1220
+ // 新进程的 info 不会自带 pending:如果这里不补,下面 #updateSession 会把操作者在冷槽上
1221
+ // 选好的模型按"没有"持久化掉 —— 意图在下一次写库时静默消失。
1222
+ live.info.pendingModelId = row.pendingModelId ?? null;
1216
1223
  this.#updateSession(live);
1224
+ // #62-2 重放:操作者在冷会话上选过的模型,现在有 agent 了,把它补发出去。
1225
+ await this.#replayPendingModel(id);
1217
1226
  // a resumed slot is the same evidence as a fresh one: the row does work
1218
1227
  this.#noteBackendCheck(
1219
1228
  String(row.backend),
@@ -1354,12 +1363,59 @@ export class SessionManager {
1354
1363
  }
1355
1364
 
1356
1365
  /** One row in the shape list()/cold()/archived() produce, from a stored row. */
1366
+ /** #62-3:把这次选择写进 agent 自己的配置。失败只记日志 —— 会话已经在新模型上跑起来了,
1367
+ * 配置文件没写成是"下次新会话的默认没跟上",不该回头把一次成功的切换报成失败。 */
1368
+ /** #62-2 兑现"冷会话上选好的模型"。
1369
+ *
1370
+ * **现读 store,不用 resume 开头那个快照**:resume 从读行到 status=ready 要 10–40 秒,这期间
1371
+ * 操作者完全可能在界面上再点一个模型(走冷分支写进 pending)。快照重放会把新的那个覆盖掉,
1372
+ * 而且重放成功后 pending 被清空 —— 新选择就静默消失了。 */
1373
+ async #replayPendingModel(id: string): Promise<void> {
1374
+ const pending = this.#store.getSession(id)?.pendingModelId ?? null;
1375
+ if (!pending) return;
1376
+ try {
1377
+ await this.setModel(id, pending, { fromReplay: true });
1378
+ } catch (e) {
1379
+ const msg = errMessage(e);
1380
+ // 结构性拒绝(agent 不认这个模型)永远不会成功,留着 pending 只会每次 resume 再撞一次;
1381
+ // 传输类失败(连接刚断)才值得下一次再试。
1382
+ if (/unknown model/i.test(msg)) {
1383
+ this.#store.setPendingModel(id, null);
1384
+ const live = this.#sessions.get(id);
1385
+ if (live) live.info.pendingModelId = null;
1386
+ }
1387
+ console.log(`[agentus] pending model ${pending} for ${id} was refused: ${msg}`);
1388
+ }
1389
+ }
1390
+
1391
+ #writeBackModelDefault(info: SessionInfo, modelId: string): void {
1392
+ try {
1393
+ const shape = this.#spawnShape(info.backend);
1394
+ if (!shape) return;
1395
+ const home = buildSpawnEnv(shape.spec).home;
1396
+ const desc = (info.models?.availableModels ?? []).find((m) => m.modelId === modelId)?.description ?? null;
1397
+ const r = writeModelDefault(info.backend, home, modelId, {
1398
+ provider: providerFromDescription(desc),
1399
+ // `hermes -p <name>` 的配置住在 `<HERMES_HOME>/profiles/<name>/config.yaml`:不传这一项
1400
+ // 就会改到默认 profile 的默认模型上,当前会话那个 profile 反而没变。
1401
+ profile: shape.spec.profile ?? null,
1402
+ });
1403
+ if (!r.ok) console.log(`[agentus] model default not written (${info.backend}): ${r.reason}`);
1404
+ else if (r.changed) {
1405
+ console.log(`[agentus] model default → ${modelId}${r.provider ? ` (provider ${r.provider})` : ""} for ${info.backend}`);
1406
+ }
1407
+ } catch (e) {
1408
+ console.log(`[agentus] model default write-back failed: ${errMessage(e)}`);
1409
+ }
1410
+ }
1411
+
1357
1412
  #infoFromRow(r: SessionRow): SessionInfo {
1358
1413
  return {
1359
1414
  id: r.id, backend: r.backend, acpSessionId: r.acpSessionId, cwd: r.cwd,
1360
1415
  workspace: r.workspace ?? null,
1361
1416
  models: (r.models ?? null) as SessionInfo["models"],
1362
1417
  contextLimit: r.contextLimit ?? null,
1418
+ pendingModelId: r.pendingModelId ?? null,
1363
1419
  title: r.title, autoTitle: r.autoTitle ?? null, titleBy: r.titleBy ?? null, status: r.status, pid: null,
1364
1420
  createdAt: r.createdAt,
1365
1421
  modes: (r.modes ?? null) as SessionModeState | null,
@@ -2998,15 +3054,45 @@ export class SessionManager {
2998
3054
  * SDK's typed surface, so it goes through the generic request() overload (verified
2999
3055
  * against hermes acp, which implements set_session_model). A cold slot has no agent
3000
3056
  * to switch: resume it first, then pick (same rule as modes). */
3001
- async setModel(id: string, modelId: string): Promise<SessionInfo> {
3002
- const s = this.#need(id);
3003
- if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready — resume it first");
3057
+ async setModel(id: string, modelId: string, opts: { fromReplay?: boolean } = {}): Promise<SessionInfo> {
3058
+ const live = this.#sessions.get(id);
3059
+ // 「能不能真的发出去」不是"map 里有没有这一条",而是**进程还活着**:崩掉的槽位仍在 map 里、
3060
+ // conn 也还挂着,对它发请求只会得到 `ACP connection closed`。所以先问进程。
3061
+ const hot = Boolean(live && live.conn && live.info.acpSessionId && live.child
3062
+ && live.child.exitCode === null && live.child.signalCode === null
3063
+ && (live.info.status === "ready" || live.info.status === "running"));
3064
+ // #62-2 冷会话:没有 agent 可以发 session/set_model —— 但"打开会话前先把它要用的模型定好"
3065
+ // 恰恰是最常见的时机(夜里回收一批,早上逐个指定再开工)。所以把意图停在行上,resume 时重放;
3066
+ // 以前这里第一行就 throw "session not ready — resume it first",等于逼操作者先花 10–40 秒唤醒。
3067
+ if (!hot) {
3068
+ const row = this.#store.getSession(id);
3069
+ if (!row) throw new Error(`no such session: ${id}`);
3070
+ const known = ((row.models ?? null) as SessionInfo["models"])?.availableModels ?? [];
3071
+ if (known.length && !known.some((m) => m.modelId === modelId)) {
3072
+ throw new Error(`unknown model: ${modelId}`);
3073
+ }
3074
+ if (!this.#store.setPendingModel(id, modelId)) throw new Error(`no such session: ${id}`);
3075
+ this.#emitSessions();
3076
+ return this.#coldInfo(id);
3077
+ }
3078
+ const s = live as NonNullable<typeof live>;
3079
+ const conn = s.conn;
3080
+ if (!conn || !s.info.acpSessionId) throw new Error("session not ready — resume it first");
3004
3081
  const known = s.info.models?.availableModels ?? [];
3005
3082
  if (known.length && !known.some((m) => m.modelId === modelId)) {
3006
3083
  throw new Error(`unknown model: ${modelId}`);
3007
3084
  }
3008
- await s.conn.request("session/set_model", { sessionId: s.info.acpSessionId, modelId });
3085
+ await conn.request("session/set_model", { sessionId: s.info.acpSessionId, modelId });
3009
3086
  if (s.info.models) s.info.models = { ...s.info.models, currentModelId: modelId };
3087
+ if (s.info.pendingModelId) s.info.pendingModelId = null;
3088
+ this.#store.setPendingModel(id, null);
3089
+ // #62-1: 只有 agent **接受**过的切换才计数 —— 被拒的条目永不加分,于是"广告了但实际不可用"
3090
+ // 的模型会自己沉到列表底部,不需要任何端点探活。
3091
+ bumpModelUsage(`${s.info.backend}|${modelId}`);
3092
+ // #62-3:选定即成为 agent 的全局默认(设置里可关)。同样只在这一行之后 —— 被拒的模型不落盘。
3093
+ // **重放不算**:冷槽上留下的意图可能被一次无人值守的 completion 唤醒带上(resume <-
3094
+ // `this.resume(e.origin)`),那时代码里没有"操作者在选模型"这件事,不该改全局配置。
3095
+ if (!opts.fromReplay && agentSettings().modelWriteBack) this.#writeBackModelDefault(s.info, modelId);
3010
3096
  this.#updateSession(s);
3011
3097
  this.#emitSessions();
3012
3098
  return s.info;
@@ -27,6 +27,7 @@ import {
27
27
  SUMMARY_MAX_INPUT_CHARS, SUMMARY_THINKING_BUDGET,
28
28
  agentSettings, getSettings, initSettings, observerSettings, planHintSettings, publicSettings,
29
29
  saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveSummary, saveTheme, summarySettings,
30
+ modelUsage,
30
31
  } from "./settings.js";
31
32
  import { ObserverHub } from "./observer/hub.js";
32
33
  import * as auth from "./auth.js";
@@ -1478,6 +1479,10 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1478
1479
  }
1479
1480
  // Operator settings: the page reads and writes this. Secrets only ever leave
1480
1481
  // through a mask (publicSettings) — an unchanged mask round-trips as "keep".
1482
+ // #62-1: 模型使用计数。列表排序要跨会话、跨设备一致,所以它在 server 上;前端只读。
1483
+ if (url.pathname === "/api/model-usage" && req.method === "GET") {
1484
+ return send(res, 200, { usage: modelUsage() });
1485
+ }
1481
1486
  if (url.pathname === "/api/settings" && req.method === "GET") {
1482
1487
  return send(res, 200, publicSettings());
1483
1488
  }
@@ -0,0 +1,179 @@
1
+ // 把"我刚选的模型"落成 agent 自己的全局默认(#62-3)。
2
+ //
3
+ // 为什么 server 直接改配置文件:agentus 换模型只影响当前会话,而 agent 侧的默认值住在它自己的
4
+ // 配置里(Hermes 是 `<HERMES_HOME>/config.yaml` 的 `model.default` + `model.provider`)。桌面/
5
+ // 沙箱部署一人一环境,"最近一次选择 = 我想要的默认"是正确语义,所以选定即回写,不弹确认。
6
+ //
7
+ // 三条纪律:
8
+ // · 只在 server 的 setModel **成功之后**、且是操作者动作时调用(重放不算,见 session-manager)。
9
+ // · 写完必须能读回来:写盘前用真 YAML 解析器验一遍 `model.default` 是不是那个值,验不过就**拒写**。
10
+ // 这是别人的全局配置文件,写坏了下次 spawn 直接起不来 —— 宁可不动,也不能"改成功但改坏"。
11
+ // · 原子写(临时文件 + rename)并保持原权限;只动 `model.default` / `model.provider` 两行。
12
+ import { chmodSync, copyFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
13
+ import path from "node:path";
14
+ import { parse as parseYaml } from "yaml";
15
+
16
+ export interface WriteBackResult {
17
+ ok: boolean;
18
+ changed: boolean;
19
+ /** 写进去的 provider;null = 没写(推不出或不安全) */
20
+ provider: string | null;
21
+ reason?: string;
22
+ }
23
+
24
+ export interface WriteBackOptions {
25
+ /** agent 在 `availableModels[].description` 里自己声明的 provider;推不出就别传 */
26
+ provider?: string | null;
27
+ /** hermes 的 `-p <profile>`:named profile 是**另一个** home,不能写到默认 profile 上 */
28
+ profile?: string | null;
29
+ }
30
+
31
+ /** agent 的配置文件在哪。
32
+ *
33
+ * `home` 是 `buildSpawnEnv().home`,对 hermes 而言**就是 HERMES_HOME 本身**(backends.ts 的
34
+ * `isolation.homeVar = "HERMES_HOME"`, `homeDefault = ~/.hermes`)—— 不能再拼一层 `.hermes`。
35
+ * 同仓的 `speech/route.ts` 也是 `path.join(home, "config.yaml")`。 */
36
+ function configPath(backend: string, home: string | null | undefined, profile: string | null | undefined): string | null {
37
+ if (backend !== "hermes" || !home) return null;
38
+ const root = profile ? path.join(home, "profiles", profile) : home;
39
+ return path.join(root, "config.yaml");
40
+ }
41
+
42
+ /** 从模型清单的 description("Provider: DeepSeek")里取 provider。
43
+ * 前缀猜(deepseek-* → deepseek)在共享 key 的多端点部署里是错的,agent 自己声明的那个才对。
44
+ *
45
+ * 但描述会带杂质:hermes 的 named catalog 拼的是 `Provider: <label> • <desc> • current`,
46
+ * 所以剥掉尾部的状态词之后还得**只接受标识符形状**(`Anthropic (Claude Sonnet 4.5)` 这种
47
+ * 写进 `model.provider` 会让 agent 起不来)—— 不合格就当没推出来,只写 default。 */
48
+ export function providerFromDescription(description: string | null | undefined): string | null {
49
+ const m = /^\s*provider:\s*(.+)$/i.exec(description ?? "");
50
+ if (!m) return null;
51
+ const name = m[1]
52
+ .replace(/[•·|,-]?\s*\b(current|active|selected|default)\b\s*$/i, "")
53
+ .replace(/[•·|,\s-]+$/g, "")
54
+ .trim();
55
+ return /^[A-Za-z0-9._:-]+$/.test(name) ? name : null;
56
+ }
57
+
58
+ const headerAt = (lines: string[]): number => lines.findIndex((l) => /^model:\s*(#.*)?\r?$/.test(l));
59
+
60
+ /** 在 `model:` 块里定位某个键的行号;列 0 的 `#` 是注释,不结束块(夹在块里是合法的)。 */
61
+ function findInModelBlock(lines: string[], key: string): number {
62
+ const start = headerAt(lines);
63
+ if (start < 0) return -1;
64
+ for (let i = start + 1; i < lines.length; i++) {
65
+ const line = lines[i].replace(/\r$/, "");
66
+ if (!line.trim()) continue;
67
+ if (/^#/.test(line)) continue; // 注释:不影响结构,继续找
68
+ if (/^\S/.test(line)) break; // 下一个顶层键
69
+ if (new RegExp(`^\\s+${key}:`).test(line)) return i;
70
+ }
71
+ return -1;
72
+ }
73
+
74
+ /** 块内第一个键的缩进(插入新键时用它,别硬编码 2 空格 —— 4 空格的文件会写出 nested mapping)。 */
75
+ function blockIndent(lines: string[]): string {
76
+ const start = headerAt(lines);
77
+ if (start < 0) return " ";
78
+ for (let i = start + 1; i < lines.length; i++) {
79
+ const line = lines[i].replace(/\r$/, "");
80
+ if (!line.trim()) continue;
81
+ if (/^#/.test(line)) continue;
82
+ if (/^\S/.test(line)) break;
83
+ const m = /^(\s+)\S/.exec(line);
84
+ if (m) return m[1];
85
+ }
86
+ return " ";
87
+ }
88
+
89
+ /** 只接受能安全裸写的标量;其余加单引号(`:`、`#`、`&`、`*` 会把标量改语义)。 */
90
+ function yamlScalar(value: string): string {
91
+ if (/^[A-Za-z0-9._:/@+-]+$/.test(value)) return value;
92
+ return `'${value.replace(/'/g, "''")}'`;
93
+ }
94
+
95
+ function setKey(lines: string[], key: string, value: string, indent: string): string[] {
96
+ const eol = lines.some((l) => l.endsWith("\r")) ? "\r" : "";
97
+ const rendered = `${indent}${key}: ${yamlScalar(value)}`;
98
+ const at = findInModelBlock(lines, key);
99
+ if (at >= 0) {
100
+ if (lines[at].replace(/\r$/, "") === rendered) return lines;
101
+ const copy = [...lines];
102
+ copy[at] = `${rendered}${eol}`;
103
+ return copy;
104
+ }
105
+ const start = headerAt(lines);
106
+ if (start < 0) return [...lines, `model:${eol}`, `${rendered}${eol}`];
107
+ const copy = [...lines];
108
+ copy.splice(start + 1, 0, `${rendered}${eol}`);
109
+ return copy;
110
+ }
111
+
112
+ /** 把 modelId(以及可安全推断时的 provider)写进 agent 的配置文件。 */
113
+ export function writeModelDefault(
114
+ backend: string,
115
+ home: string | null | undefined,
116
+ modelId: string,
117
+ options: WriteBackOptions = {},
118
+ ): WriteBackResult {
119
+ const profile = options.profile ? String(options.profile).trim() || null : null;
120
+ const file = configPath(backend, home, profile);
121
+ if (!file) return { ok: false, changed: false, provider: null, reason: `${backend}: no known config file` };
122
+ if (!existsSync(file)) return { ok: false, changed: false, provider: null, reason: `no config at ${file}` };
123
+ if (!modelId) return { ok: false, changed: false, provider: null, reason: "empty model id" };
124
+
125
+ let text: string;
126
+ let mode: number;
127
+ try {
128
+ text = readFileSync(file, "utf8");
129
+ mode = statSync(file).mode & 0o777;
130
+ } catch (e) {
131
+ return { ok: false, changed: false, provider: null, reason: `unreadable: ${(e as Error).message}` };
132
+ }
133
+
134
+ const lines = text.split("\n");
135
+ // 行内 flow(`model: {…}`)和锚点(`model: &m`)都不是"块":追加会造出第二个顶层 `model:`。
136
+ // 这种形状一律拒写 —— 我们只改块,不重排别人的文件。
137
+ const header = lines.find((l) => /^model:/.test(l));
138
+ if (header !== undefined && headerAt(lines) < 0) {
139
+ return { ok: false, changed: false, provider: null, reason: `model: is not a plain block (${header.trim().slice(0, 40)})` };
140
+ }
141
+
142
+ const provider = options.provider ? String(options.provider).trim() : null;
143
+ const indent = blockIndent(lines);
144
+ let work = lines;
145
+ if (provider) work = setKey(work, "provider", provider, indent);
146
+ work = setKey(work, "default", modelId, indent);
147
+
148
+ const next = work.join("\n");
149
+ if (next === text) return { ok: true, changed: false, provider };
150
+
151
+ // 写盘前的最后一道闸:真解析一遍,两个值都得读回来。任何一个不成立就一个字都不写。
152
+ let doc: unknown;
153
+ try {
154
+ doc = parseYaml(next);
155
+ } catch (e) {
156
+ return { ok: false, changed: false, provider, reason: `refusing to write: result would not parse (${(e as Error).message.split("\n")[0]})` };
157
+ }
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" };
161
+ }
162
+ if (provider && model.provider !== provider) {
163
+ return { ok: false, changed: false, provider, reason: "refusing to write: model.provider would not read back" };
164
+ }
165
+
166
+ // 配置里可能有 api_key:比 0600 宽的一律收紧。
167
+ const targetMode = mode & 0o077 ? 0o600 : mode;
168
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
169
+ try {
170
+ copyFileSync(file, `${file}.bak-${Date.now()}`); // 别人的配置,改之前留一份
171
+ writeFileSync(tmp, next, { mode: targetMode });
172
+ chmodSync(tmp, targetMode);
173
+ renameSync(tmp, file);
174
+ } catch (e) {
175
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
176
+ return { ok: false, changed: false, provider, reason: `write failed: ${(e as Error).message}` };
177
+ }
178
+ return { ok: true, changed: true, provider };
179
+ }
@@ -209,6 +209,9 @@ export interface AgentSettings {
209
209
  * (`context`/`messages`/`plan`) is NOT gated: it cannot change anything, and it is what the
210
210
  * 工作播报 observer runs on. */
211
211
  cockpitSend: boolean;
212
+ /** #62-3:选定模型后回写 agent 自己的配置文件(`model.default`)。默认开 —— 一人一环境里
213
+ * "我最近选的模型就是我想要的默认"是正确语义;留这个开关只是为了能关掉别动别人的配置。 */
214
+ modelWriteBack: boolean;
212
215
  /** Let a finished background process wake the agent with its own result (issue #22). ON by default:
213
216
  * this is what the CLI and the gateway already do, and the failure it prevents is the silent one —
214
217
  * a session that looks finished while a build is still running, whose result nobody ever reads. */
@@ -230,7 +233,7 @@ export interface AgentSettings {
230
233
  }
231
234
 
232
235
  export const AGENT_DEFAULT: AgentSettings = {
233
- idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false, completionWake: true,
236
+ idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false, completionWake: true, modelWriteBack: true,
234
237
  showSilentBackground: false, recallItemChars: 400, recallTotalChars: 12000,
235
238
  };
236
239
 
@@ -427,9 +430,27 @@ export interface Settings {
427
430
  agent: AgentSettings;
428
431
  observer: ObserverSettings;
429
432
  summary: SummarySettings;
433
+ /** 模型使用计数(#62-1):key = `<backend>|<provider>|<modelId>`,value = 成功切换的次数。
434
+ * 放 server 而不是前端 localStorage —— 排序要跨会话、跨设备一致,且所有切换入口都收敛
435
+ * 在 server 的 setModel 上,记这一处就不漏。被 agent 拒的条目永不计数。 */
436
+ modelUsage: Record<string, number>;
430
437
  updatedAt: number;
431
438
  }
432
439
 
440
+ /** 计数表的上限:一个 agent 广告几百个模型,键会脏(换过 provider 名字等),别让它无限长。 */
441
+ const MODEL_USAGE_MAX = 3000;
442
+
443
+ function pickModelUsage(raw: unknown): Record<string, number> {
444
+ const out: Record<string, number> = {};
445
+ if (!raw || typeof raw !== "object") return out;
446
+ const entries = Object.entries(raw as Record<string, unknown>)
447
+ .filter(([k, v]) => typeof k === "string" && k.length <= 200 && typeof v === "number" && Number.isFinite(v) && v > 0)
448
+ .sort((a, b) => Number(b[1]) - Number(a[1]))
449
+ .slice(0, MODEL_USAGE_MAX);
450
+ for (const [k, v] of entries) out[k] = Math.min(1e9, Math.round(Number(v)));
451
+ return out;
452
+ }
453
+
433
454
  const DEFAULTS: VoiceSettings = {
434
455
  provider: "dashscope",
435
456
  baseUrl: "",
@@ -454,7 +475,7 @@ let legacyFile = "";
454
475
  let cache: Settings = {
455
476
  voice: { ...DEFAULTS }, theme: { ...THEME_DEFAULT }, call: { ...CALL_DEFAULT },
456
477
  prefs: { ...PREFS_DEFAULT }, agent: { ...AGENT_DEFAULT },
457
- observer: { ...OBSERVER_DEFAULT }, summary: { ...SUMMARY_DEFAULT }, updatedAt: 0,
478
+ observer: { ...OBSERVER_DEFAULT }, summary: { ...SUMMARY_DEFAULT }, modelUsage: {}, updatedAt: 0,
458
479
  };
459
480
  /** keys that came from env/.env (shown as "auto-detected" and used when settings are empty) */
460
481
  let envCreds: { apiKey: string; baseUrl: string; source: string } | null = null;
@@ -521,6 +542,7 @@ export function initSettings(dataDir: string, store: Store): Settings {
521
542
  agent: pickAgent(stored.agent),
522
543
  observer: pickObserver(stored.observer),
523
544
  summary: pickSummary(stored.summary),
545
+ modelUsage: pickModelUsage((stored as Partial<Settings>).modelUsage),
524
546
  updatedAt: Number((stored as Settings).updatedAt ?? 0),
525
547
  };
526
548
  // an unset baseUrl/apiKey bootstrap from the environment: the operator said the
@@ -577,6 +599,7 @@ function pickAgent(raw: unknown): AgentSettings {
577
599
  take("recallItemChars", AGENT_RECALL_ITEM_RANGE);
578
600
  take("recallTotalChars", AGENT_RECALL_TOTAL_RANGE);
579
601
  if (typeof r.cockpitSend === "boolean") out.cockpitSend = r.cockpitSend;
602
+ if (typeof r.modelWriteBack === "boolean") out.modelWriteBack = r.modelWriteBack;
580
603
  if (typeof r.completionWake === "boolean") out.completionWake = r.completionWake;
581
604
  if (typeof r.showSilentBackground === "boolean") out.showSilentBackground = r.showSilentBackground;
582
605
  }
@@ -646,11 +669,27 @@ function persist(): void {
646
669
  db.setSetting("agent", cache.agent);
647
670
  db.setSetting("observer", cache.observer);
648
671
  db.setSetting("summary", cache.summary);
672
+ db.setSetting("modelUsage", cache.modelUsage);
649
673
  } catch (e) {
650
674
  console.error(`[agentus] cannot write settings: ${(e as Error).message}`);
651
675
  }
652
676
  }
653
677
 
678
+ /** 模型计数快照(#62-1)。前端拿它给模型列表排序 —— 只是读,改不了。 */
679
+ export function modelUsage(): Record<string, number> {
680
+ return cache.modelUsage;
681
+ }
682
+
683
+ /** 一次**成功**的模型切换。调用点在 session-manager.setModel 里 agent 接受之后 —— 被拒的
684
+ * 条目不加分,于是"广告了但实际不可用"的模型会自己沉底,不需要任何端点探活。 */
685
+ export function bumpModelUsage(key: string): void {
686
+ if (!key) return;
687
+ const next = { ...cache.modelUsage };
688
+ next[key] = (next[key] ?? 0) + 1;
689
+ cache = { ...cache, modelUsage: pickModelUsage(next), updatedAt: Date.now() };
690
+ persist();
691
+ }
692
+
654
693
  export function getSettings(): Settings {
655
694
  return cache;
656
695
  }
@@ -908,6 +947,10 @@ export function saveAgent(patch: Record<string, unknown>): Settings {
908
947
  if (typeof patch.cockpitSend !== "boolean") throw new Error("invalid cockpitSend: expected true or false");
909
948
  a.cockpitSend = patch.cockpitSend;
910
949
  }
950
+ if ("modelWriteBack" in patch) {
951
+ if (typeof patch.modelWriteBack !== "boolean") throw new Error("invalid modelWriteBack: expected true or false");
952
+ a.modelWriteBack = patch.modelWriteBack;
953
+ }
911
954
  if ("completionWake" in patch) {
912
955
  if (typeof patch.completionWake !== "boolean") throw new Error("invalid completionWake: expected true or false");
913
956
  a.completionWake = patch.completionWake;
@@ -168,6 +168,7 @@ export interface SessionRow {
168
168
  /** Models the agent advertised (JSON) + the operator's context-window override. */
169
169
  models?: unknown;
170
170
  contextLimit?: number | null;
171
+ pendingModelId?: string | null;
171
172
  /** The directory the cockpit works in for this slot: the file/terminal panel's
172
173
  * root, and the cwd a cold slot is resumed with. Deliberately separate from
173
174
  * `cwd` — a live ACP child cannot be re-cd'd (its cwd was fixed at newSession),
@@ -194,6 +195,7 @@ interface RawSessionRow {
194
195
  workspace?: string | null;
195
196
  home?: string | null;
196
197
  models?: string | null; context_limit?: number | null;
198
+ pending_model_id?: string | null;
197
199
  system?: number | null;
198
200
  }
199
201
 
@@ -263,6 +265,7 @@ function rowToSession(r: RawSessionRow): SessionRow {
263
265
  system: Boolean(r.system),
264
266
  models: parseJson(r.models),
265
267
  contextLimit: r.context_limit ?? null,
268
+ pendingModelId: r.pending_model_id ?? null,
266
269
  };
267
270
  }
268
271
 
@@ -479,6 +482,9 @@ export class Store {
479
482
  this.#db.exec("update sessions set auto_title = title where auto_title is null or auto_title = ''");
480
483
  // integer column, so it gets its own migration (the loop above assumes text)
481
484
  if (!cols.has("context_limit")) this.#db.exec("alter table sessions add column context_limit integer");
485
+ // #62-2: a cold slot has no agent to send session/set_model to, so the operator's pick is
486
+ // PARKED here and replayed on the next resume. text, so it rides the loop above.
487
+ if (!cols.has("pending_model_id")) this.#db.exec("alter table sessions add column pending_model_id text");
482
488
  // WHICH SESSIONS THE COCKPIT OWNS (issue #16): 1 = the 工作播报 observer, 0 = the operator's work.
483
489
  // Its own column rather than a title convention, because this flag decides whether a row is
484
490
  // allowed into the rail, the warm-slot ranking and the cockpit CLI's scope — a rename must not be
@@ -1702,6 +1708,14 @@ export class Store {
1702
1708
  }
1703
1709
 
1704
1710
  /** The operator's declared context window for a slot (null clears it). */
1711
+ /** #62-2: the model a cold slot should adopt when it next wakes. */
1712
+ setPendingModel(id: string, modelId: string | null): boolean {
1713
+ const info = this.#db
1714
+ .prepare("update sessions set pending_model_id = ? where id = ?")
1715
+ .run(modelId || null, id);
1716
+ return Number(info.changes ?? 0) > 0;
1717
+ }
1718
+
1705
1719
  setContextLimit(id: string, limit: number | null): boolean {
1706
1720
  const info = this.#db
1707
1721
  .prepare("update sessions set context_limit = ? where id = ?")
@@ -88,6 +88,9 @@ export interface SessionInfo {
88
88
  * what the agent reports via usage_update or this — the operator's declared window.
89
89
  * null = use whatever the agent reports. */
90
90
  contextLimit?: number | null;
91
+ /** 冷会话上「下一个模型」(#62-2):没有 agent 进程可以发 session/set_model,于是把意图存下来,
92
+ * resume 成功后重放一次并清空。null = 没有待生效的选择。 */
93
+ pendingModelId?: string | null;
91
94
  /** The window the operator declared for the CURRENT model (remembered per model, the way
92
95
  * hermes-studio stores a context length per provider+model). Precedence for the gauge:
93
96
  * contextLimit (this session) → this → the agent's own usage_update.size. */