agentus 0.6.0 → 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,12 @@ 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), and a
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
45
+ can see, trust and edit** (the cockpit reports which project skills a repo has and which tier you
46
+ trust, and writes the trust level back to config — it rides the same status channel; a released
47
+ Hermes silently skips untrusted project skills without saying so), and a
43
48
  **DeepSeek web backend** (`search` + `extract` on top of `DEEPSEEK_API_KEY` — DeepSeek has no separate
44
49
  search key).
45
50
  They live on the ACP branches of the fork
@@ -55,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
55
60
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
56
61
  last segment sorts, so "newest" is a `sort -V`.
57
62
 
58
- **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,
59
64
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
60
65
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
61
66
  ever looks stale.
@@ -35,7 +35,10 @@ 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 自己接着干,而不是回合就这么结束),以及 **DeepSeek 的 web 后端**
38
+ 中继成一个新回合,agent 自己接着干,而不是回合就这么结束),快照里的每个进程还会带
39
+ `notifyOnComplete` —— 驾驶舱据此只显示「会唤醒会话」的那些,而不是一屏噪音、**项目 skill 的可见 / 可信任 / 可写**
40
+ (驾驶舱会告诉 agent 这个仓库有哪些项目 skill、你信任哪个层级,并能把信任层级写回配置 —— 信号走
41
+ 同一条 status 通道;裸装 Hermes 遇到没信任的项目 skill 是悄悄跳过、不吭声的),以及 **DeepSeek 的 web 后端**
39
42
  (`search` + `extract` 都复用
40
43
  `DEEPSEEK_API_KEY` —— DeepSeek 没有单独的搜索密钥)。它们在 fork
41
44
  [`LiangYang666/hermes-agent`](https://github.com/LiangYang666/hermes-agent) 的 ACP 分支上,
@@ -49,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
49
52
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
50
53
  可排序,所以「最新」就是一次 `sort -V`。
51
54
 
52
- **当前集成构建:`agentus/v0.22.0/merge-20261019-2348`** —— fork 上最新的那个 tag,基座 Hermes
55
+ **当前集成构建:`agentus/v0.22.0/merge-20261020-2337`** —— fork 上最新的那个 tag,基座 Hermes
53
56
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
54
57
  以它为准。
55
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.6.0",
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": {
@@ -59,6 +59,7 @@
59
59
  "terminal-palette": "tsx scripts/qa/terminal-palette.mts",
60
60
  "android-trust-merge": "node scripts/qa/android-trust-merge.mjs",
61
61
  "cards-persist": "node scripts/qa/cards-persist.mjs",
62
+ "cold-paging": "tsx scripts/qa/cold-paging.mts",
62
63
  "plan-hint-sweep": "node scripts/qa/plan-hint-sweep.mjs",
63
64
  "plan-service-sweep": "node scripts/qa/plan-service-sweep.mjs",
64
65
  "plan-mcp-sweep": "node scripts/qa/plan-mcp-sweep.mjs",
@@ -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
@@ -0,0 +1,82 @@
1
+ /** Paging for the COLD session list — the rows that exist on disk with no process behind them.
2
+ *
3
+ * Issue #34: the page used to be ONE global top-N by `lastAt`. Every consumer (rail grouping, the
4
+ * rail's 展开其余 counter, search, `cockpit session list --workspace`) then treated that page as the
5
+ * complete set, so the busiest workspace ate the whole page and every other workspace's cold
6
+ * sessions vanished. It is not a cosmetic loss: a cold row is the ONLY handle that can wake that
7
+ * session, so "not in the page" means "unreachable from the UI, even though the transcript is fine".
8
+ *
9
+ * The intent behind the old cap is still real — crash debris accumulates without anyone choosing it
10
+ * — so it is kept, but moved to the right axis: **N per workspace** (the same axis the rail already
11
+ * groups by) plus a much larger total ceiling as the anti-bloat backstop.
12
+ *
13
+ * Pure on purpose: the caller decides which rows EXIST (the cockpit's own scope rule), this decides
14
+ * which of them survive the page. That way the rule is tested without a server or a browser. */
15
+
16
+ import type { SessionInfo } from "@agentus/shared";
17
+
18
+ /** How many cold rows a single workspace keeps by default. The rail shows `RAIL_RECENT` (5) before
19
+ * 「展开其余」, so 20 is the expand depth, not the visible depth. */
20
+ export const COLD_PER_WORKSPACE = 20;
21
+
22
+ /** Hard ceiling across all workspaces. Chosen to be far above any real installation's workspace
23
+ * count × 20 while still bounding what a crash-storm can push into one WS frame. */
24
+ export const COLD_TOTAL_MAX = 400;
25
+
26
+ /** The cockpit CLI asks for a deeper page than the rail: an agent asking "what is going on in this
27
+ * workspace" is not browsing, and it filters by `--workspace` on top. */
28
+ export const COLD_PER_WORKSPACE_COCKPIT = 50;
29
+
30
+ export interface ColdPage<T> {
31
+ /** Newest-first, at most `perWorkspace` per workspace and `totalMax` overall. */
32
+ rows: T[];
33
+ /** How many cold rows existed before paging — so a caller can tell an empty cockpit from a
34
+ * truncated one. */
35
+ total: number;
36
+ /** `total - rows.length`: the rows this page left out. Non-zero means "this is a partial view". */
37
+ omitted: number;
38
+ }
39
+
40
+ /** Which bucket a cold row belongs to: the workspace the operator sees in the rail, falling back to
41
+ * the cwd exactly like `sessionRow()` does — the two must not disagree, or a row would be paged by
42
+ * one key and displayed under another. */
43
+ export function coldKeyOf(row: Pick<SessionInfo, "workspace" | "cwd">): string {
44
+ return row.workspace || row.cwd || "/";
45
+ }
46
+
47
+ /** Sort rule shared with `list()`: recent activity first, ties broken by creation. */
48
+ export function byActivity(a: Pick<SessionInfo, "lastAt" | "createdAt">, b: Pick<SessionInfo, "lastAt" | "createdAt">): number {
49
+ return (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt;
50
+ }
51
+
52
+ /**
53
+ * Bucket-aware cold paging.
54
+ *
55
+ * `sorted` must already be in the caller's display order (newest first). Each workspace keeps the
56
+ * first `perWorkspace` rows of that order; the survivors are merged and re-sorted so the overall list
57
+ * still reads newest-first, then cut at `totalMax` only if the merge exceeds it.
58
+ */
59
+ export function coldPage<T extends Pick<SessionInfo, "workspace" | "cwd" | "lastAt" | "createdAt">>(
60
+ sorted: readonly T[],
61
+ perWorkspace: number = COLD_PER_WORKSPACE,
62
+ totalMax: number = COLD_TOTAL_MAX,
63
+ ): ColdPage<T> {
64
+ const total = sorted.length;
65
+ const per = Math.max(1, Math.floor(perWorkspace));
66
+ const cap = Math.max(per, Math.floor(totalMax));
67
+
68
+ const buckets = new Map<string, T[]>();
69
+ for (const row of sorted) {
70
+ const key = coldKeyOf(row);
71
+ const bucket = buckets.get(key);
72
+ if (!bucket) buckets.set(key, [row]);
73
+ else if (bucket.length < per) bucket.push(row);
74
+ // else: this workspace already has its page — the rest of its rows are the ones we omit
75
+ }
76
+
77
+ const kept: T[] = [];
78
+ for (const bucket of buckets.values()) kept.push(...bucket);
79
+ kept.sort(byActivity);
80
+ const rows = kept.length > cap ? kept.slice(0, cap) : kept;
81
+ return { rows, total, omitted: total - rows.length };
82
+ }
@@ -14,6 +14,14 @@ import type {
14
14
  RequestPermissionResponse,
15
15
  } from "@agentclientprotocol/sdk";
16
16
  import { BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
17
+ import {
18
+ byActivity,
19
+ coldPage as pageCold,
20
+ COLD_PER_WORKSPACE,
21
+ COLD_PER_WORKSPACE_COCKPIT,
22
+ COLD_TOTAL_MAX,
23
+ type ColdPage,
24
+ } from "./cold-page.js";
17
25
  import { SKILLS_ROOT, installSkills, resolveSkillsDir } from "../skills/install.js";
18
26
  import { classifyError, checkedHealth, effectiveArgs, handshakeFrom, rowToSpec } from "./registry.js";
19
27
  import type { BackendHandshake, CheckErrorCode } from "./registry.js";
@@ -39,7 +47,7 @@ import type {
39
47
  } from "../../../shared/src/index.js";
40
48
  import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, clampTitle, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
41
49
  import { PLAN_GUIDANCE_ACTIVE, PLAN_GUIDANCE_INTRO, PLAN_GUIDANCE_NATIVE } from "../../../shared/src/index.js";
42
- import { PREFS_DEFAULT, type PlanHintSettings } from "../settings.js";
50
+ import { PREFS_DEFAULT, agentSettings, type PlanHintSettings } from "../settings.js";
43
51
 
44
52
  // The cockpit CLI an agent is handed (issue #15). Resolved from THIS module's location, so it points
45
53
  // at the same tree the server runs from — a checkout and an installed package alike (`bin/` is in the
@@ -945,17 +953,26 @@ export class SessionManager {
945
953
  * reachable in the rail (the old rule showed them under 已归档; the older one dropped them out of
946
954
  * the list entirely, which is how a transcript became unreachable while still on disk).
947
955
  *
948
- * Capped, unlike `archived()`: crash debris accumulates without anyone choosing it, and the rail
949
- * has its own recent-N rule anyway. */
950
- cold(limit = 20): SessionInfo[] {
951
- return this.#store
956
+ * Paged **per workspace** (#34), unlike `archived()` which is complete: crash debris accumulates
957
+ * without anyone choosing it, so a cap is still right — but the axis has to be the one the rail
958
+ * groups by. A global top-N let the busiest workspace eat the page, and since a cold row is the
959
+ * only handle that can wake its session, everything it pushed out became unreachable from the UI
960
+ * while the transcript sat fine on disk. See `cold-page.ts`. */
961
+ cold(perWorkspace = COLD_PER_WORKSPACE, totalMax = COLD_TOTAL_MAX): SessionInfo[] {
962
+ return this.coldPage(perWorkspace, totalMax).rows;
963
+ }
964
+
965
+ /** The same page plus `total`/`omitted`, so a caller can say "this is a partial view" instead of
966
+ * presenting a truncated list as the whole cockpit. */
967
+ coldPage(perWorkspace = COLD_PER_WORKSPACE, totalMax = COLD_TOTAL_MAX): ColdPage<SessionInfo> {
968
+ const sorted = this.#store
952
969
  .listSessions(true)
953
970
  // system rows are the cockpit's own (see list()): never offered as a slot to wake
954
971
  .filter((r) => r.archivedAt == null && !r.system && !this.#sessions.has(r.id))
955
972
  .map((r) => this.#infoFromRow(r))
956
973
  // cold rows by the same rule as live ones (recent chat first), THEN take the page
957
- .sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt)
958
- .slice(0, limit);
974
+ .sort(byActivity);
975
+ return pageCold(sorted, perWorkspace, totalMax);
959
976
  }
960
977
 
961
978
  /** One row in the shape list()/cold()/archived() produce, from a stored row. */
@@ -1811,7 +1828,10 @@ export class SessionManager {
1811
1828
  * reasons, e.g. a title change) then wiped a row the page had just drawn from a `background` frame.
1812
1829
  * An explicit `[]` is the same discipline the three rail buckets use. */
1813
1830
  backgroundSnapshots(): { sessionId: string; tasks: BackgroundTask[] }[] {
1814
- 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
+ }));
1815
1835
  }
1816
1836
 
1817
1837
  #emitSessions(): void {
@@ -1916,7 +1936,7 @@ export class SessionManager {
1916
1936
  const bgTasks = backgroundTasksOf(u);
1917
1937
  if (bgTasks) {
1918
1938
  live.background = bgTasks;
1919
- this.#emit({ t: "background", sessionId: live.info.id, tasks: bgTasks });
1939
+ this.#emit({ t: "background", sessionId: live.info.id, tasks: visibleBackgroundTasks(bgTasks) });
1920
1940
  return;
1921
1941
  }
1922
1942
  const finishedText = completionTextOf(u);
@@ -2233,10 +2253,17 @@ export class SessionManager {
2233
2253
  /** The cockpit CLI's read view (issue #15): the sessions the OPERATOR can see, in one list —
2234
2254
  * live and cold, never 已归档 (that is the operator's shelf, not work in flight).
2235
2255
  *
2236
- * Cold rows are capped by `cold()`'s own page size on purpose: an agent asking "what is going on"
2237
- * gets the recent cockpit, not a scrollback of every session this install ever made. */
2256
+ * Cold rows are paged per workspace by `cold()`, deeper than the rail's own expand depth: an agent
2257
+ * asking "what is going on" is not browsing, and it filters by `--workspace` on top — but it still
2258
+ * must not receive a scrollback of every session this install ever made. */
2238
2259
  cockpitRows(): SessionInfo[] {
2239
- return [...this.list(), ...this.cold(50)];
2260
+ return [...this.list(), ...this.cold(COLD_PER_WORKSPACE_COCKPIT)];
2261
+ }
2262
+
2263
+ /** The cockpit's cold page together with what it left out, so `session list` can report
2264
+ * `capped` honestly instead of presenting a truncated view as the whole cockpit (#34). */
2265
+ cockpitCold(): ColdPage<SessionInfo> {
2266
+ return this.coldPage(COLD_PER_WORKSPACE_COCKPIT);
2240
2267
  }
2241
2268
 
2242
2269
  /** Approvals a session is WAITING on right now — the one fact worth waking another agent for. */
@@ -2674,7 +2701,7 @@ function completionTextOf(u: Record<string, unknown>): string | null {
2674
2701
 
2675
2702
  /** The agent's running-process snapshot (#22). Dropped (not defaulted) when `tasks` is not an array:
2676
2703
  * a malformed frame must never blank a row the operator is reading. */
2677
- function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null {
2704
+ export function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null {
2678
2705
  if (hermesKindOf(u) !== "background") return null;
2679
2706
  const raw = hermesPayload(u).tasks;
2680
2707
  if (!Array.isArray(raw)) return null;
@@ -2692,11 +2719,28 @@ function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null
2692
2719
  status,
2693
2720
  uptimeSeconds: typeof r.uptimeSeconds === "number" ? r.uptimeSeconds : undefined,
2694
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,
2695
2726
  });
2696
2727
  }
2697
2728
  return out;
2698
2729
  }
2699
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
+
2700
2744
  function statusKindOf(u: Record<string, unknown>): string | undefined {
2701
2745
  const meta = u?._meta as { hermes?: { statusKind?: unknown } } | undefined;
2702
2746
  const kind = meta?.hermes?.statusKind;
@@ -195,6 +195,10 @@ export interface ListQuery {
195
195
  /** bucket the page instead of returning it flat. `workspace` is the only dimension today because it
196
196
  * is the one the operator's rail groups by — anything else would be an invented taxonomy. */
197
197
  by?: "workspace";
198
+ /** How many cold rows the caller's own page left out (issue #34). Set by the route from
199
+ * `cockpitCold()`: absent/0 means "you are looking at everything", non-zero means the view is
200
+ * partial and the answer must say so. Never a wire parameter. */
201
+ coldOmitted?: number;
198
202
  /** fixed clock for tests; never set by a caller over the wire */
199
203
  now?: number;
200
204
  }
@@ -207,7 +211,12 @@ export interface ListResult {
207
211
  meta: {
208
212
  total: number;
209
213
  returned: number;
214
+ /** TRUE means "this is not the whole answer" — either the list limit cut it, or (`cold_omitted`)
215
+ * the cold page itself did. Before #34 only the first was visible, so an agent could not tell a
216
+ * complete view from a truncated one. */
210
217
  capped: boolean;
218
+ /** cold rows that existed but never reached this function (0 = nothing was hidden) */
219
+ cold_omitted: number;
211
220
  /** the filters that were actually applied, echoed back so a caller cannot misread an empty list
212
221
  * as "there is nothing" when what happened is "your filter matched nothing" */
213
222
  applied: Record<string, string | number | boolean>;
@@ -308,7 +317,10 @@ export function listSessions(rows: CockpitSessionRow[], q: ListQuery = {}): List
308
317
  const limit = Math.max(1, Math.min(LIST_LIMIT_MAX, Math.round(q.limit ?? LIST_LIMIT_DEFAULT)));
309
318
  applied.limit = limit;
310
319
  const total = out.length;
311
- const capped = total > limit;
320
+ const coldOmitted = Math.max(0, Math.floor(q.coldOmitted ?? 0));
321
+ // `capped` answers "can I trust this as the whole picture?", so a cold page that dropped rows counts
322
+ // even when the list limit itself did not bite (#34).
323
+ const capped = total > limit || coldOmitted > 0;
312
324
  const page = out.slice(0, limit);
313
325
 
314
326
  let groups: ListResult["groups"];
@@ -331,7 +343,7 @@ export function listSessions(rows: CockpitSessionRow[], q: ListQuery = {}): List
331
343
  }));
332
344
  }
333
345
 
334
- return { rows: page, ...(groups ? { groups } : {}), meta: { total, returned: Math.min(total, limit), capped, applied } };
346
+ return { rows: page, ...(groups ? { groups } : {}), meta: { total, returned: Math.min(total, limit), capped, cold_omitted: coldOmitted, applied } };
335
347
  }
336
348
 
337
349
  function normPath(p: string): string {
@@ -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";
@@ -635,7 +636,9 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
635
636
  const isOperator = !caller && auth.verifyMachineToken(/^Bearer\s+(.+)$/i.exec(String(req.headers.authorization ?? ""))?.[1] ?? undefined);
636
637
  if (!caller && !isOperator) return send(res, 403, { error: COCKPIT_ERRORS.badToken });
637
638
  // Scope: what the operator can see. 已归档 is out, on purpose — see `cockpitRows`.
638
- const rows = mgr.cockpitRows();
639
+ // The cold page is taken once so its `omitted` count can travel with the answer (#34).
640
+ const cold = mgr.cockpitCold();
641
+ const rows = [...mgr.list(), ...cold.rows];
639
642
  const rowOf = (id: string): SessionInfo | null => rows.find((s) => s.id === id) ?? null;
640
643
  const summarize = (s: SessionInfo) => sessionRow(s, {
641
644
  plan: store.getPlan(s.id),
@@ -673,6 +676,7 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
673
676
  all: sp.get("all") === "1" || sp.get("all") === "true",
674
677
  includeSelf: sp.get("include_self") === "1" || sp.get("include_self") === "true",
675
678
  by: by as ListQuery["by"],
679
+ coldOmitted: cold.omitted,
676
680
  };
677
681
  if (query.sort && !["last", "created", "name"].includes(query.sort)) {
678
682
  return send(res, 400, { error: COCKPIT_ERRORS.badRequest, message: `--sort wants last|created|name, got "${query.sort}"` });
@@ -1589,12 +1593,19 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1589
1593
  const row = store.getSession(id);
1590
1594
  if (!row) return { route: null, why: "会话不在库里" };
1591
1595
  const be = store.listBackends().find((b) => b.id === row.backend);
1592
- 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;
1593
1604
  // The provider keys live in this box's agent home, which is a property of the runtime —
1594
1605
  // not of the vendor that happens to be driving this session. A backend that owns no home
1595
1606
  // (mock, qoder) therefore still resolves a `deepseek:…` model through the Hermes home.
1596
1607
  const home = (be ? planFor(be).home : null) ?? HERMES_DEFAULT_HOME;
1597
- return routeForModel(models.currentModelId ?? null, home);
1608
+ return routeForModel(modelId, home);
1598
1609
  };
1599
1610
  // 语音总结 (the message speaker's long press): ONE stored message, made listenable.
1600
1611
  //
@@ -1624,6 +1635,18 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1624
1635
  if (spoken.length <= maxChars) {
1625
1636
  return send(res, 200, { ok: true, via: "verbatim", text: spoken, chars: spoken.length, ms: 0, maxChars });
1626
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
+ }
1627
1650
  try {
1628
1651
  // The operator's rule: bill this to the model he is already talking to. The voice
1629
1652
  // endpoint stays as the fallback (and is the whole route when followSession is off).
@@ -1635,6 +1658,10 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1635
1658
  prompt: cfg.prompt.trim() || SUMMARY_PROMPT_DEFAULT,
1636
1659
  model,
1637
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,
1638
1665
  endpoint: own.route ? { baseUrl: own.route.baseUrl, apiKey: own.route.apiKey } : undefined,
1639
1666
  });
1640
1667
  // Newlines are the one thing the model keeps ignoring: 「不要换行」 in the prompt is a
@@ -1646,7 +1673,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1646
1673
  // echo the bucket: the note prints 「约 150 字」 and a summary that silently used the
1647
1674
  // configured number would make the menu look broken
1648
1675
  maxChars,
1649
- ms: r.ms, truncated: r.truncated, model,
1676
+ ms: r.ms, model,
1650
1677
  // which account this call was billed to — the note prints it, and a fallback that
1651
1678
  // silently used the OTHER provider would be a lie about the cost
1652
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;