agentus 0.6.1 → 0.7.0

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
@@ -56,7 +56,7 @@ option instead of being skipped in silence), **background-job completion wake-up
56
56
  background process comes back as a turn of its own), and a **DeepSeek web backend** (search +
57
57
  extract on the model key) — come from the fork's integration build instead. That is a five-line install:
58
58
  [§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
59
- `agentus/v0.22.0/merge-20261019-2348`, on Hermes `v0.22.0`.
59
+ `agentus/v0.22.0/merge-20261020-2337`, on Hermes `v0.22.0`.
60
60
 
61
61
  The first slot you start will use the Hermes home **your own agent already uses**
62
62
  (`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
package/README.zh-CN.md CHANGED
@@ -50,7 +50,7 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
50
50
  以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
51
51
  装法就五行:
52
52
  [§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
53
- `agentus/v0.22.0/merge-20261019-2348`,基座 Hermes `v0.22.0`。
53
+ `agentus/v0.22.0/merge-20261020-2337`,基座 Hermes `v0.22.0`。
54
54
 
55
55
  你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
56
56
  指定了另一个 —— 见下面的「HERMES_HOME」。
@@ -39,7 +39,9 @@ mode that survives a restart** and `session/load`, a free-form **context budget*
39
39
  cockpit's message stream, **per-process skill roots** (`HERMES_EXTERNAL_SKILLS_DIRS`, which is how a
40
40
  slot sees the cockpit's own skill without anything being written into your `~/.hermes`),
41
41
  **background-job completion wake-ups** (a finished `terminal(background=true)` job is relayed back as
42
- a turn of its own, so the agent resumes instead of the turn simply ending), **project skills the agent
42
+ a turn of its own, so the agent resumes instead of the turn simply ending), and each row of the running
43
+ snapshot now says whether that process is the kind that wakes the session (`notifyOnComplete`) — which
44
+ is how the cockpit shows only the ones that mean 「not finished yet」 instead of a wall of noise, **project skills the agent
43
45
  can see, trust and edit** (the cockpit reports which project skills a repo has and which tier you
44
46
  trust, and writes the trust level back to config — it rides the same status channel; a released
45
47
  Hermes silently skips untrusted project skills without saying so), and a
@@ -58,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
58
60
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
59
61
  last segment sorts, so "newest" is a `sort -V`.
60
62
 
61
- **Current integration build: `agentus/v0.22.0/merge-20261019-2348`** — the newest tag on the fork,
63
+ **Current integration build: `agentus/v0.22.0/merge-20261020-2337`** — the newest tag on the fork,
62
64
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
63
65
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
64
66
  ever looks stale.
@@ -35,7 +35,8 @@ brew install node # 或者 https://nodejs.org/en/download
35
35
  (带 256K/512K/768K/1M 预设)、把记忆召回 / 存记忆提示送进驾驶舱消息流的 **status 通道**,以及
36
36
  **按进程指定的技能根**(`HERMES_EXTERNAL_SKILLS_DIRS` —— 驾驶舱靠它让槽位看见自己的 skill,
37
37
  不往你的 `~/.hermes` 写任何东西)、**后台任务完成唤醒**(`terminal(background=true)` 的进程跑完会被
38
- 中继成一个新回合,agent 自己接着干,而不是回合就这么结束)、**项目 skill 的可见 / 可信任 / 可写**
38
+ 中继成一个新回合,agent 自己接着干,而不是回合就这么结束),快照里的每个进程还会带
39
+ `notifyOnComplete` —— 驾驶舱据此只显示「会唤醒会话」的那些,而不是一屏噪音、**项目 skill 的可见 / 可信任 / 可写**
39
40
  (驾驶舱会告诉 agent 这个仓库有哪些项目 skill、你信任哪个层级,并能把信任层级写回配置 —— 信号走
40
41
  同一条 status 通道;裸装 Hermes 遇到没信任的项目 skill 是悄悄跳过、不吭声的),以及 **DeepSeek 的 web 后端**
41
42
  (`search` + `extract` 都复用
@@ -51,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
51
52
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
52
53
  可排序,所以「最新」就是一次 `sort -V`。
53
54
 
54
- **当前集成构建:`agentus/v0.22.0/merge-20261019-2348`** —— fork 上最新的那个 tag,基座 Hermes
55
+ **当前集成构建:`agentus/v0.22.0/merge-20261020-2337`** —— fork 上最新的那个 tag,基座 Hermes
55
56
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
56
57
  以它为准。
57
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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": {
@@ -480,9 +480,13 @@ const agent = () => ({
480
480
  _meta: { hermes: { kind: "background", tasks: rows } },
481
481
  content: { type: "text", text: "" },
482
482
  });
483
+ // `notifyOnComplete: true` because this fixture IS the wake shape: it ends with a completion
484
+ // carrier, i.e. the agent is waiting for that result (issue #35). The cockpit filters silent
485
+ // processes out by default, so a fixture without the flag would render no row at all — and the
486
+ // wake sweeps would fail for the right reason but the wrong cause.
483
487
  const running = [{
484
488
  id: taskId, command: "npm run build --workspace packages/web", pid: 4242,
485
- status: "running", uptimeSeconds: 12,
489
+ status: "running", uptimeSeconds: 12, notifyOnComplete: true,
486
490
  }];
487
491
  void send(agent._conn, sessionId, snapshot(running)).catch(() => undefined);
488
492
  setTimeout(() => {
@@ -503,6 +507,20 @@ const agent = () => ({
503
507
  }, runMs + 1200);
504
508
  }
505
509
 
510
+ // `[bgquiet]` — a SILENT background process: running, no notify, no completion carrier. This is
511
+ // what the agent's own polling looks like, and the cockpit hides it by default (issue #35).
512
+ if (/\[bgquiet\]/.test(text)) {
513
+ const quietId = `bgq-${sessionId}-1`;
514
+ void send(agent._conn, sessionId, {
515
+ sessionUpdate: "agent_thought_chunk",
516
+ _meta: { hermes: { kind: "background", tasks: [{
517
+ id: quietId, command: "tail -f /tmp/agentus-qa.log", pid: 4343,
518
+ status: "running", uptimeSeconds: 5, notifyOnComplete: false,
519
+ }] } },
520
+ content: { type: "text", text: "" },
521
+ }).catch(() => undefined);
522
+ }
523
+
506
524
  // `[table]` — a table fixture for the sorting / full-screen sweep (scripts/qa/table-sweep.mjs).
507
525
  // Numeric AND text cells, deliberately NOT in any sorted order, and a local file link plus a
508
526
  // relative image, so "clicking a header reorders the rows" and "a relative path resolves" are
@@ -47,7 +47,7 @@ import type {
47
47
  } from "../../../shared/src/index.js";
48
48
  import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, clampTitle, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
49
49
  import { PLAN_GUIDANCE_ACTIVE, PLAN_GUIDANCE_INTRO, PLAN_GUIDANCE_NATIVE } from "../../../shared/src/index.js";
50
- import { PREFS_DEFAULT, type PlanHintSettings } from "../settings.js";
50
+ import { PREFS_DEFAULT, agentSettings, type PlanHintSettings } from "../settings.js";
51
51
 
52
52
  // The cockpit CLI an agent is handed (issue #15). Resolved from THIS module's location, so it points
53
53
  // at the same tree the server runs from — a checkout and an installed package alike (`bin/` is in the
@@ -1828,7 +1828,10 @@ export class SessionManager {
1828
1828
  * reasons, e.g. a title change) then wiped a row the page had just drawn from a `background` frame.
1829
1829
  * An explicit `[]` is the same discipline the three rail buckets use. */
1830
1830
  backgroundSnapshots(): { sessionId: string; tasks: BackgroundTask[] }[] {
1831
- return [...this.#sessions.values()].map((s) => ({ sessionId: s.info.id, tasks: s.background }));
1831
+ return [...this.#sessions.values()].map((s) => ({
1832
+ sessionId: s.info.id,
1833
+ tasks: visibleBackgroundTasks(s.background),
1834
+ }));
1832
1835
  }
1833
1836
 
1834
1837
  #emitSessions(): void {
@@ -1933,7 +1936,7 @@ export class SessionManager {
1933
1936
  const bgTasks = backgroundTasksOf(u);
1934
1937
  if (bgTasks) {
1935
1938
  live.background = bgTasks;
1936
- this.#emit({ t: "background", sessionId: live.info.id, tasks: bgTasks });
1939
+ this.#emit({ t: "background", sessionId: live.info.id, tasks: visibleBackgroundTasks(bgTasks) });
1937
1940
  return;
1938
1941
  }
1939
1942
  const finishedText = completionTextOf(u);
@@ -2698,7 +2701,7 @@ function completionTextOf(u: Record<string, unknown>): string | null {
2698
2701
 
2699
2702
  /** The agent's running-process snapshot (#22). Dropped (not defaulted) when `tasks` is not an array:
2700
2703
  * a malformed frame must never blank a row the operator is reading. */
2701
- function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null {
2704
+ export function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null {
2702
2705
  if (hermesKindOf(u) !== "background") return null;
2703
2706
  const raw = hermesPayload(u).tasks;
2704
2707
  if (!Array.isArray(raw)) return null;
@@ -2716,11 +2719,28 @@ function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null
2716
2719
  status,
2717
2720
  uptimeSeconds: typeof r.uptimeSeconds === "number" ? r.uptimeSeconds : undefined,
2718
2721
  exitCode: typeof r.exitCode === "number" ? r.exitCode : null,
2722
+ // Explicit boolean, never `undefined` by omission (#35): the page filters on this, and an
2723
+ // old agent that does not send it must land on `false` (silent) rather than a third state
2724
+ // nobody renders.
2725
+ notifyOnComplete: r.notifyOnComplete === true,
2719
2726
  });
2720
2727
  }
2721
2728
  return out;
2722
2729
  }
2723
2730
 
2731
+ /** 默认降噪 (#35): 只留下「完成后会唤醒 agent」的进程。静默进程是 agent 自己轮询的活,
2732
+ * 操作者不需要看见 —— 而唤醒型才是「这个会话还没收工」的强信号。开关打开才回到全量,
2733
+ * 保留 fork 原本「快照是这个进程存在过的唯一见证」的设计价值, 但决定权交给操作者。
2734
+ *
2735
+ * WHY THIS IS A SEPARATE STEP FROM PARSING (measured while running the reap/background sweep):
2736
+ * `live.background` feeds more than rendering — the idle reaper treats a running child as
2737
+ * 「not idle」 and refuses to SIGTERM the agent that owns it. Filtering at parse time made every
2738
+ * silent process invisible to the reaper too, i.e. a slot could be reaped while it still had a
2739
+ * live child (the exact orphan #22 removed). So: store everything, hide at the wire. */
2740
+ export function visibleBackgroundTasks(tasks: BackgroundTask[]): BackgroundTask[] {
2741
+ return agentSettings().showSilentBackground ? tasks : tasks.filter((t) => t.notifyOnComplete === true);
2742
+ }
2743
+
2724
2744
  function statusKindOf(u: Record<string, unknown>): string | undefined {
2725
2745
  const meta = u?._meta as { hermes?: { statusKind?: unknown } } | undefined;
2726
2746
  const kind = meta?.hermes?.statusKind;
@@ -15,7 +15,7 @@
15
15
  // The base URL is the operator's — every Bailian workspace id is different, and the
16
16
  // whole point is that this is NOT the coding-plan endpoint. No URLs or keys are baked in.
17
17
  import WebSocket from "ws";
18
- import { getSettings, dashscopeUrls } from "./settings.js";
18
+ import { getSettings, dashscopeUrls, SUMMARY_MODEL_DEFAULT } from "./settings.js";
19
19
 
20
20
  export class DashscopeError extends Error {
21
21
  constructor(message: string, readonly code: "not_configured" | "upstream" | "timeout" | "bad_audio") {
@@ -241,8 +241,10 @@ export function openDashscopeStream(opts: {
241
241
  * the operator's existing voice endpoint and key are enough — there is no second model to
242
242
  * configure and no agent, no session and no history involved.
243
243
  *
244
- * The input is capped at `maxChars × 20` characters (30k ≈ 15k tokens is already the ceiling for a
245
- * 30-second listen): a 200 KB pasted log must not turn a long press into a bill.
244
+ * The input is NOT truncated any more (#36). It used to be capped at `maxChars × 20` characters,
245
+ * which punished exactly the case this feature exists for — a long agent reply — while the case
246
+ * the cap protected against (a pasted 200 KB log) is rare and is now refused outright by the
247
+ * caller with a visible 409. Half a summary that looks finished is worse than an error.
246
248
  */
247
249
  export async function summarizeForSpeech(opts: {
248
250
  text: string;
@@ -251,18 +253,20 @@ export async function summarizeForSpeech(opts: {
251
253
  model: string;
252
254
  timeoutSec: number;
253
255
  /** Where to send it. Absent = the voice endpoint (settings → 语音), which is the old, always-on
254
- * route. Present = the SESSION's own provider (see speech/route.ts) — the operator asked for the
255
- * summary to be billed to whatever model he is actually talking to, not to a second vendor. */
256
+ * route. Present = the SESSION's own provider (see speech/route.ts) — the operator asked for
257
+ * the summary to be billed to whatever model he is actually talking to, not to a second vendor. */
256
258
  endpoint?: { baseUrl: string; apiKey: string };
257
- }): Promise<{ text: string; truncated: boolean; ms: number }> {
259
+ /** qwen3-family only: `enable_thinking: true` + `thinking_budget: N`. The operator's rule is
260
+ * 「开着思考,但只给一点点」 — a capped think reads the message better than a non-reasoning
261
+ * model, without the 235-token wander measured on the default budget (4000). Sending this to
262
+ * a non-qwen3 model would be a lie, so the caller decides and we just transmit. */
263
+ thinkingBudget?: number;
264
+ }): Promise<{ text: string; ms: number }> {
258
265
  const { key, compat } = opts.endpoint
259
266
  ? { key: opts.endpoint.apiKey, compat: opts.endpoint.baseUrl.replace(/\/+$/, "") }
260
267
  : creds();
261
268
  const v = getSettings().voice;
262
- const limit = Math.max(400, opts.maxChars * 20);
263
- const body = opts.text.trim();
264
- const truncated = body.length > limit;
265
- const input = truncated ? `${body.slice(0, limit)}\n…(后面还有 ${body.length - limit} 字没发)` : body;
269
+ const input = opts.text.trim();
266
270
  const ac = new AbortController();
267
271
  const timer = setTimeout(() => ac.abort(), Math.max(5, opts.timeoutSec) * 1000);
268
272
  const started = Date.now();
@@ -271,18 +275,25 @@ export async function summarizeForSpeech(opts: {
271
275
  method: "POST",
272
276
  headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
273
277
  body: JSON.stringify({
274
- model: opts.model || (opts.endpoint ? "" : "qwen-flash"),
278
+ model: opts.model || (opts.endpoint ? "" : SUMMARY_MODEL_DEFAULT),
275
279
  messages: [
276
280
  { role: "system", content: opts.prompt.replace(/\{maxChars\}/g, String(opts.maxChars)) },
277
281
  { role: "user", content: input },
278
282
  ],
279
283
  // No temperature knob: this surface is not a playground, and the default has produced
280
- // the steadiest length compliance. `max_tokens` guards against a chatty model — and it
281
- // must leave room for REASONING tokens, which count against the same budget: measured on
282
- // deepseek-v4-flash, a 480-token cap was spent entirely on reasoning_content and the
283
- // visible answer came back empty with finish_reason=length. 1200 is the floor that makes a
284
- // reasoning model answer; for a plain one it is only a ceiling nobody reaches.
285
- max_tokens: Math.max(1200, opts.maxChars * 6),
284
+ // the steadiest length compliance. `max_completion_tokens` (NOT `max_tokens`): it bounds
285
+ // thinking and answer TOGETHER and has no 32768 ceiling, so a capped-think call cannot
286
+ // come back empty with finish_reason=length. Measured on deepseek-v4-flash, a 480-token
287
+ // cap was spent entirely on reasoning_content and the visible answer was empty; 1200 is
288
+ // the floor that makes a reasoning model answer, and for a plain one it is only a ceiling
289
+ // nobody reaches. The think budget is added on top so capping the think never eats the
290
+ // answer.
291
+ max_completion_tokens: Math.max(1200, opts.maxChars * 6) + (opts.thinkingBudget ?? 0),
292
+ // qwen3 family (#36): thinking ON, budget 200. `thinking_budget` and `reasoning_effort`
293
+ // must never travel together (the vendor rejects the pair), so this is the only knob.
294
+ ...(opts.thinkingBudget
295
+ ? { enable_thinking: true, thinking_budget: opts.thinkingBudget }
296
+ : {}),
286
297
  }),
287
298
  signal: ac.signal,
288
299
  });
@@ -305,7 +316,7 @@ export async function summarizeForSpeech(opts: {
305
316
  throw new DashscopeError(why, "upstream");
306
317
  }
307
318
  void v;
308
- return { text: out, truncated, ms: Date.now() - started };
319
+ return { text: out, ms: Date.now() - started };
309
320
  } catch (e) {
310
321
  if ((e as Error).name === "AbortError") {
311
322
  throw new DashscopeError(`summary timed out after ${opts.timeoutSec}s`, "timeout");
@@ -23,6 +23,7 @@ import { dictationContext, hotwordsFor, vocabularyOf } from "./hotwords.js";
23
23
  import { openDashscopeStream, summarizeForSpeech } from "./dashscope.js";
24
24
  import {
25
25
  SUMMARY_CHARS_RANGE, SUMMARY_MODEL_DEFAULT, SUMMARY_PROMPT_DEFAULT,
26
+ SUMMARY_MAX_INPUT_CHARS, SUMMARY_THINKING_BUDGET,
26
27
  agentSettings, getSettings, initSettings, observerSettings, planHintSettings, publicSettings,
27
28
  saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveSummary, saveTheme, summarySettings,
28
29
  } from "./settings.js";
@@ -1592,12 +1593,19 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1592
1593
  const row = store.getSession(id);
1593
1594
  if (!row) return { route: null, why: "会话不在库里" };
1594
1595
  const be = store.listBackends().find((b) => b.id === row.backend);
1595
- const models = (row.models ?? {}) as { currentModelId?: string | null };
1596
+ // LIVE first, store second (#36). The ACP handshake reports the model, and the live
1597
+ // session object holds it — but `sessions.models` is flushed later (observed: JSON null
1598
+ // for every row on this box), so reading the store alone answered 「agent 未握手」 and
1599
+ // every summary silently fell back to the voice endpoint's qwen-flash.
1600
+ const live = mgr.list().find((s) => s.id === id);
1601
+ const modelId = live?.models?.currentModelId
1602
+ ?? ((row.models ?? {}) as { currentModelId?: string | null }).currentModelId
1603
+ ?? null;
1596
1604
  // The provider keys live in this box's agent home, which is a property of the runtime —
1597
1605
  // not of the vendor that happens to be driving this session. A backend that owns no home
1598
1606
  // (mock, qoder) therefore still resolves a `deepseek:…` model through the Hermes home.
1599
1607
  const home = (be ? planFor(be).home : null) ?? HERMES_DEFAULT_HOME;
1600
- return routeForModel(models.currentModelId ?? null, home);
1608
+ return routeForModel(modelId, home);
1601
1609
  };
1602
1610
  // 语音总结 (the message speaker's long press): ONE stored message, made listenable.
1603
1611
  //
@@ -1627,6 +1635,18 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1627
1635
  if (spoken.length <= maxChars) {
1628
1636
  return send(res, 200, { ok: true, via: "verbatim", text: spoken, chars: spoken.length, ms: 0, maxChars });
1629
1637
  }
1638
+ // Over the ceiling we REFUSE, loudly (#36). The old behaviour summarised the head and said
1639
+ // so in a footnote — but a summary that drops the second half is worse than none, because
1640
+ // that is where the conclusion lives. 20k chars is far above any reply worth listening to
1641
+ // and still stops a pasted 200 KB log from becoming an invoice.
1642
+ if (spoken.length > SUMMARY_MAX_INPUT_CHARS) {
1643
+ return send(res, 409, {
1644
+ error: `这条消息 ${spoken.length} 字,超出语音总结上限 ${SUMMARY_MAX_INPUT_CHARS} 字 —— 直接朗读,或截取要听的那段`,
1645
+ code: "too_long",
1646
+ chars: spoken.length,
1647
+ limit: SUMMARY_MAX_INPUT_CHARS,
1648
+ });
1649
+ }
1630
1650
  try {
1631
1651
  // The operator's rule: bill this to the model he is already talking to. The voice
1632
1652
  // endpoint stays as the fallback (and is the whole route when followSession is off).
@@ -1638,6 +1658,10 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1638
1658
  prompt: cfg.prompt.trim() || SUMMARY_PROMPT_DEFAULT,
1639
1659
  model,
1640
1660
  timeoutSec: cfg.timeoutSec,
1661
+ // Thinking stays ON for qwen3 models, capped at 200 tokens (operator's call, #36):
1662
+ // a little reasoning reads the sentence better than none, and 200 keeps it from
1663
+ // wandering the way the default 4000 did (measured 235 output tokens on a 40-char ask).
1664
+ thinkingBudget: /^qwen3/i.test(model) ? SUMMARY_THINKING_BUDGET : undefined,
1641
1665
  endpoint: own.route ? { baseUrl: own.route.baseUrl, apiKey: own.route.apiKey } : undefined,
1642
1666
  });
1643
1667
  // Newlines are the one thing the model keeps ignoring: 「不要换行」 in the prompt is a
@@ -1649,7 +1673,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1649
1673
  // echo the bucket: the note prints 「约 150 字」 and a summary that silently used the
1650
1674
  // configured number would make the menu look broken
1651
1675
  maxChars,
1652
- ms: r.ms, truncated: r.truncated, model,
1676
+ ms: r.ms, model,
1653
1677
  // which account this call was billed to — the note prints it, and a fallback that
1654
1678
  // silently used the OTHER provider would be a lie about the cost
1655
1679
  route: own.route ? "session" : "voice",
@@ -189,10 +189,16 @@ export interface AgentSettings {
189
189
  * this is what the CLI and the gateway already do, and the failure it prevents is the silent one —
190
190
  * a session that looks finished while a build is still running, whose result nobody ever reads. */
191
191
  completionWake: boolean;
192
+ /** 显示不唤醒的后台进程 (issue #35). OFF by default: the row and the rail badge answer
193
+ * 「这个会话还在干活吗」, and a silent process the agent is polling itself is noise — the
194
+ * agent knows it is there, the operator does not need to. Turned ON, every running process
195
+ * shows, which is what the fork's original "a snapshot is the only witness" design wanted. */
196
+ showSilentBackground: boolean;
192
197
  }
193
198
 
194
199
  export const AGENT_DEFAULT: AgentSettings = {
195
200
  idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false, completionWake: true,
201
+ showSilentBackground: false,
196
202
  };
197
203
 
198
204
  /** The numeric half of `AgentSettings` — the keys the range-checked helpers may write. */
@@ -298,8 +304,26 @@ export const SUMMARY_CHARS_RANGE: [number, number] = [40, 400];
298
304
  export const SUMMARY_TIMEOUT_RANGE: [number, number] = [5, 60];
299
305
  /** Measured against this operator's workspace, 2026-10-18: `qwen-flash` answered a 40-character
300
306
  * ask with 67 prompt / 20 completion tokens. `qwen3-flash` and `qwen-turbo` are 404 there ("Model
301
- * not exist"), and `qwen3.8-flash` spent 235 output tokens thinking about the same sentence. */
302
- export const SUMMARY_MODEL_DEFAULT = "qwen-flash";
307
+ * not exist"), and `qwen3.8-flash` spent 235 output tokens thinking about the same sentence.
308
+ *
309
+ * 2026-10-20 (operator's call): the fallback IS `qwen3.8-flash` anyway — it is the model this
310
+ * workspace already talks to, and `qwen-flash` was a second vendor's answer to a problem that has
311
+ * a proper knob. The 235-token overspend is what `SUMMARY_THINKING_BUDGET` below exists for:
312
+ * thinking stays ON (it reads the message better) but is capped at 200 tokens. */
313
+ export const SUMMARY_MODEL_DEFAULT = "qwen3.8-flash";
314
+
315
+ /** Thinking cap for qwen3-family models, in tokens (`thinking_budget`). 200 is deliberate: enough
316
+ * to hold the sentence together, too small to wander — measured 235 → 200 keeps the visible
317
+ * answer intact at a fifth of the default 4000. NOT used for non-qwen3 models (DeepSeek ignores
318
+ * the field, and sending it there would be a lie about what we asked for). */
319
+ export const SUMMARY_THINKING_BUDGET = 200;
320
+
321
+ /** The most characters this feature will ever hand a model. Above it the call is REFUSED with a
322
+ * visible 409 instead of quietly summarising the head (#36) — a summary that silently drops the
323
+ * second half is worse than no summary, because the conclusion usually lives there. 20k ≈ 10k
324
+ * tokens: longer than any agent reply worth listening to, short enough that a pasted 200 KB log
325
+ * cannot turn one long press into a bill. */
326
+ export const SUMMARY_MAX_INPUT_CHARS = 20_000;
303
327
 
304
328
  export const SUMMARY_PROMPT_DEFAULT = [
305
329
  "下面是我在一个工作会话里拿到的一段输出。请把它改写成一段「用嘴念出来」的摘要:",
@@ -492,6 +516,7 @@ function pickAgent(raw: unknown): AgentSettings {
492
516
  take("warmTtlHours", AGENT_WARM_TTL_RANGE);
493
517
  if (typeof r.cockpitSend === "boolean") out.cockpitSend = r.cockpitSend;
494
518
  if (typeof r.completionWake === "boolean") out.completionWake = r.completionWake;
519
+ if (typeof r.showSilentBackground === "boolean") out.showSilentBackground = r.showSilentBackground;
495
520
  }
496
521
  return out;
497
522
  }
@@ -807,6 +832,10 @@ export function saveAgent(patch: Record<string, unknown>): Settings {
807
832
  if (typeof patch.completionWake !== "boolean") throw new Error("invalid completionWake: expected true or false");
808
833
  a.completionWake = patch.completionWake;
809
834
  }
835
+ if ("showSilentBackground" in patch) {
836
+ if (typeof patch.showSilentBackground !== "boolean") throw new Error("invalid showSilentBackground: expected true or false");
837
+ a.showSilentBackground = patch.showSilentBackground;
838
+ }
810
839
  cache = { ...cache, agent: a, updatedAt: Date.now() };
811
840
  persist();
812
841
  return cache;
@@ -56,8 +56,10 @@ const BUILTIN: Record<string, { baseUrl: string; keys: string[]; baseEnv?: strin
56
56
 
57
57
  /**
58
58
  * ACP model ids arrive as `deepseek:deepseek-v4-flash` (provider prefix, wire form) or
59
- * `openrouter:anthropic/claude-x`; older agents use `provider/model`. Returns "" for a bare id
60
- * with no prefix — the caller then has to ask config.yaml who owns it.
59
+ * `openrouter:anthropic/claude-x`. Returns "" for a bare id with no prefix — the caller then has to
60
+ * ask config.yaml who owns it. The `provider/model` form is NOT handled here (a prefix here can be
61
+ * a vendor namespace rather than a provider); `slashProvider()` below resolves it with the provider
62
+ * tables in hand (#36).
61
63
  */
62
64
  export function splitModelId(raw: string | null | undefined): { provider: string; model: string } {
63
65
  const id = String(raw ?? "").trim();
@@ -91,6 +93,21 @@ function readConfig(home: string | null): Record<string, unknown> {
91
93
  const asString = (v: unknown): string => (typeof v === "string" ? v.trim() : typeof v === "number" ? String(v) : "");
92
94
  const trimSlash = (u: string): string => u.replace(/\/+$/, "");
93
95
 
96
+ /**
97
+ * `provider/model` — the second id form a session can report. Only stripped when the prefix really
98
+ * names a provider this box knows (declared in config.yaml or in the builtin table): a catalogue
99
+ * slug like `anthropic/claude-x` must survive intact, or an OpenRouter model id would be mangled
100
+ * into provider `anthropic` + model `claude-x`.
101
+ */
102
+ function slashProvider(cfgProviders: Record<string, unknown>, model: string): { provider: string; model: string } {
103
+ const slash = model.indexOf("/");
104
+ if (slash <= 0) return { provider: "", model };
105
+ const cand = model.slice(0, slash).trim().toLowerCase();
106
+ if (!(cand in BUILTIN) && !(cand in cfgProviders)) return { provider: "", model };
107
+ const rest = model.slice(slash + 1).trim();
108
+ return rest ? { provider: cand, model: rest } : { provider: "", model };
109
+ }
110
+
94
111
  /**
95
112
  * Resolve the route for one session's model. `home` is the home of the backend that session runs
96
113
  * on (`planFor(row).home`) — reading a DIFFERENT agent's config would hand this call a key its
@@ -101,10 +118,19 @@ export function routeForModel(modelId: string | null | undefined, home: string |
101
118
  why: string;
102
119
  } {
103
120
  const parsed = splitModelId(modelId);
104
- const model = parsed.model;
105
- if (!model) return { route: null, why: "会话还没报出模型(agent 未握手)" };
121
+ if (!parsed.model) return { route: null, why: "会话还没报出模型(agent 未握手)" };
106
122
 
107
123
  const cfg = readConfig(home);
124
+ // Slash form is a LAST resort after the colon form (#36): `deepseek/deepseek-v4-flash` is what
125
+ // this session reports, and before this the split returned no provider at all, so the summary
126
+ // silently fell back to the voice endpoint on `qwen-flash`.
127
+ const slash = parsed.provider
128
+ ? { provider: parsed.provider, model: parsed.model }
129
+ : slashProvider(
130
+ (cfg.providers && typeof cfg.providers === "object" ? cfg.providers : {}) as Record<string, unknown>,
131
+ parsed.model,
132
+ );
133
+ const model = slash.model;
108
134
  // An alias row may rename the model as well as point at a provider (`lab-qwen38-27b` →
109
135
  // `qwen38-27b`); the wire id is not the name the vendor knows.
110
136
  const aliasRow = (aliases: Record<string, unknown>): Record<string, unknown> | null => {
@@ -121,7 +147,7 @@ export function routeForModel(modelId: string | null | undefined, home: string |
121
147
  // Who owns this model? The id's own prefix wins; otherwise config.yaml's default provider, and
122
148
  // only when the model really is that default (a session can be running a model the default no
123
149
  // longer names — then guessing the provider would send the request to the wrong vendor).
124
- let provider = parsed.provider;
150
+ let provider = slash.provider;
125
151
  if (!provider) {
126
152
  if (aliasNow) provider = asString(aliasNow.provider).toLowerCase();
127
153
  }
@@ -263,6 +263,11 @@ export interface BackgroundTask {
263
263
  status: "running" | "exited";
264
264
  uptimeSeconds?: number;
265
265
  exitCode?: number | null;
266
+ /** Does this process OWE the session a wake-up (`notify_on_complete`)? Issue #35: that is the
267
+ * difference between 「agent 已经收工」 and 「agent 还在等这次唤醒才能继续」. Optional — an
268
+ * older agent never sends it, and `undefined` must read as "silent", never as "unknown so
269
+ * show it anyway". */
270
+ notifyOnComplete?: boolean;
266
271
  }
267
272
 
268
273
  export interface WebNotice {