agentus 0.6.1 → 0.8.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-20261021-1400`, 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-20261021-1400`,基座 Hermes `v0.22.0`。
54
54
 
55
55
  你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
56
56
  指定了另一个 —— 见下面的「HERMES_HOME」。
package/bin/agentus.mjs CHANGED
@@ -49,7 +49,9 @@ Commands (client side — they need the identity the cockpit gives an agent it s
49
49
  agentus session show <id> one session in detail + any approval it waits on
50
50
  agentus session messages <id> [--tail N]
51
51
  agentus session plan <id>
52
- agentus session send <id> <text…> deliver a message into another session (operator-gated)
52
+ agentus session send <id> <text…> deliver a message into another session (operator-gated);
53
+ add --wake to also start a cold target
54
+ agentus session wake <id> start a cold session's process (same switch as send)
53
55
  agentus capabilities the CLI's contract, self-describing
54
56
  agentus skill [--path] the CLI's manual, as shipped in the package
55
57
 
package/bin/cli.mjs CHANGED
@@ -54,7 +54,10 @@ another one
54
54
  agentus session messages <id> [--tail N]
55
55
  the last N messages, each cut to the documented size
56
56
  agentus session plan <id> the plan object the operator's card renders
57
- agentus session send <id> <text…> deliver a message into another session (stdin if no text)
57
+ agentus session send <id> <text…> [--wake]
58
+ deliver a message into another session (stdin if no text);
59
+ --wake also starts the target when it is cold
60
+ agentus session wake <id> start a cold session's process (same switch as send)
58
61
 
59
62
  agentus capabilities the contract: commands, fields, truncation, scope, send state
60
63
  agentus skill [--path] this CLI's manual, as shipped in the package
@@ -292,7 +295,7 @@ if (cmd === "capabilities") {
292
295
  // The text is the plain words after the id — an agent writing a sentence should not have to
293
296
  // build JSON to say it. With no words, stdin is accepted: either a JSON `{"message": "…"}` (what
294
297
  // older callers sent) or the raw text.
295
- let message = args.slice(1).join(" ").trim();
298
+ let message = args.slice(1).filter((a) => a !== "--wake").join(" ").trim();
296
299
  if (!message) {
297
300
  const raw = await readStdin();
298
301
  if (raw) {
@@ -310,7 +313,7 @@ if (cmd === "capabilities") {
310
313
  res = await fetch(`${EP}/api/cockpit/send`, {
311
314
  method: "POST",
312
315
  headers: headers(true),
313
- body: JSON.stringify({ to: id, message }),
316
+ body: JSON.stringify({ to: id, message, ...(args.includes("--wake") ? { wake: true } : {}) }),
314
317
  });
315
318
  } catch (e) {
316
319
  fail("unreachable", `the cockpit did not answer: ${e?.message ?? e}`);
@@ -319,14 +322,46 @@ if (cmd === "capabilities") {
319
322
  if (!res.ok) {
320
323
  // The refusal meanings a model must not blur (see `capabilities.discipline`): none of these is
321
324
  // "delivered", and target_busy specifically means the target saw NOTHING.
322
- fail(String(body?.error ?? `http_${res.status}`), String(body?.error ?? `the cockpit answered HTTP ${res.status}`));
325
+ // The refusal's MESSAGE is the part a model can act on (it names --wake, or that a box is
326
+ // archived); the code stays the stable token. Dropping the message made `target_cold` a dead
327
+ // end that looked identical to a true prohibition.
328
+ fail(String(body?.error ?? `http_${res.status}`),
329
+ String(body?.message ?? body?.error ?? `the cockpit answered HTTP ${res.status}`));
323
330
  }
324
331
  out({
325
332
  success: true,
326
333
  data: body,
327
334
  meta: {
328
335
  schema_version: 1,
329
- note: "delivered means the target agent received it and started a turn — it does NOT mean it read or agreed. Only the operator sees the reply.",
336
+ note: body?.delivered_after_wake
337
+ ? "the target was COLD: this call woke it (a process now exists) and then delivered. delivered means it received the message and started a turn — not that it read or agreed."
338
+ : "delivered means the target agent received it and started a turn — it does NOT mean it read or agreed. Only the operator sees the reply.",
339
+ },
340
+ });
341
+ } else if (verb === "wake") {
342
+ // #41: the explicit, gated version of what opening a cold session in the rail does. Kept separate
343
+ // from send because it is a DECISION with a cost (a process, its memory) — an agent must be able
344
+ // to say 「I woke it」 without also having said something.
345
+ if (!id) fail("bad_request", "usage: agentus session wake <id>");
346
+ if (args.length > 1) fail("bad_request", `unexpected argument "${args[1]}" (usage: agentus session wake <id>)`);
347
+ let res;
348
+ try {
349
+ res = await fetch(`${EP}/api/cockpit/wake`, { method: "POST", headers: headers(true), body: JSON.stringify({ id }) });
350
+ } catch (e) {
351
+ fail("unreachable", `the cockpit did not answer: ${e?.message ?? e}`);
352
+ }
353
+ const body = await res.json().catch(() => null);
354
+ if (!res.ok) {
355
+ fail(String(body?.error ?? `http_${res.status}`), String(body?.message ?? body?.error ?? `the cockpit answered HTTP ${res.status}`));
356
+ }
357
+ out({
358
+ success: true,
359
+ data: body,
360
+ meta: {
361
+ schema_version: 1,
362
+ note: body?.woke
363
+ ? "the target now has a process and accepts send. Waking costs one process — do it for work you actually need."
364
+ : "the target was already live: nothing was started.",
330
365
  },
331
366
  });
332
367
  } else {
@@ -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-20261021-1400`** — 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-20261021-1400`** —— 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.8.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": {
@@ -451,6 +451,33 @@ const agent = () => ({
451
451
  }
452
452
  }
453
453
 
454
+ // `[recall]` — 记忆召回行带条目(issue #37):与 `[status]` 同一个载体,多一个 `items`,
455
+ // 形状照 fork `acp_adapter/events.py:make_status_cb`。故意给一条带 tags、一条超长(验证展开后
456
+ // 换行而不是把行撑破),并用 `truncated` 说明后面还有 —— 界面上要能看出折叠、展开、截断三件事。
457
+ // `[recall:N]` 出 N 条(上限 60):一轮召回几十条是线上的常态,而「展开就刷屏」只有在条目多到
458
+ // 超过定高时才看得出来 —— 3 条永远撑不满那个框(操作者 2026-10-21 要求定高可滚动)。
459
+ const recallM = /\[recall(?::(\d+))?\]/.exec(text);
460
+ if (/recall/.test(text)) { const fs = await import("node:fs"); fs.appendFileSync("/tmp/mock-recall.log", `text=${JSON.stringify(text)} m=${JSON.stringify(recallM && recallM[0])}\n`); }
461
+ if (recallM) {
462
+ const want = Math.min(Math.max(Number(recallM[1] ?? 3), 1), 60);
463
+ const seed = [
464
+ { text: "用户偏好:回复一律中文,技术术语可留英文" },
465
+ { text: "Agentus 的 live 端口是 8788,dev 是 8901,端口权威在 ~/.hermes/cache/agentus/ports.env", tags: ["port", "agentus"] },
466
+ { text: "发布红线:会重启 live 的改动必须等明确指令;纯前端可以直接 promote" + "(这段故意写得长一些,用来验证展开后的条目会换行而不是把行撑破)".repeat(2) },
467
+ ];
468
+ const items = Array.from({ length: want }, (_, i) => {
469
+ const base = seed[i % seed.length];
470
+ return i < seed.length ? base : { ...base, text: `第 ${i + 1} 条:${base.text}` };
471
+ });
472
+ setTimeout(() => {
473
+ void send(agent._conn, sessionId, {
474
+ sessionUpdate: "agent_thought_chunk",
475
+ _meta: { hermes: { kind: "status", statusKind: "lifecycle", text: `👁️ Hindsight — recalled ${want} memories`, items, truncated: true } },
476
+ content: { type: "text", text: "" },
477
+ }).catch(() => undefined);
478
+ }, Math.min(slow * 2, 900));
479
+ }
480
+
454
481
  // `[blank]` — the SAME carrier with its `_meta` stripped, i.e. the shape a status line takes when
455
482
  // the sentence is lost before us (measured 2026-10-17: hermes set `update._meta` while the ACP
456
483
  // model's field is `field_meta`, so every status shipped as a bare empty thought). The cockpit must
@@ -480,9 +507,13 @@ const agent = () => ({
480
507
  _meta: { hermes: { kind: "background", tasks: rows } },
481
508
  content: { type: "text", text: "" },
482
509
  });
510
+ // `notifyOnComplete: true` because this fixture IS the wake shape: it ends with a completion
511
+ // carrier, i.e. the agent is waiting for that result (issue #35). The cockpit filters silent
512
+ // processes out by default, so a fixture without the flag would render no row at all — and the
513
+ // wake sweeps would fail for the right reason but the wrong cause.
483
514
  const running = [{
484
515
  id: taskId, command: "npm run build --workspace packages/web", pid: 4242,
485
- status: "running", uptimeSeconds: 12,
516
+ status: "running", uptimeSeconds: 12, notifyOnComplete: true,
486
517
  }];
487
518
  void send(agent._conn, sessionId, snapshot(running)).catch(() => undefined);
488
519
  setTimeout(() => {
@@ -503,6 +534,54 @@ const agent = () => ({
503
534
  }, runMs + 1200);
504
535
  }
505
536
 
537
+ // `[bg-delegation]` — the OTHER producer of background frames (issue #39): a live
538
+ // `delegate_task(background=true)`. Same carrier as `[bg]`, but the row is a subagent: no pid, no
539
+ // command line, a GOAL, and `taskCount` children behind one row. This is what the fork's relay now
540
+ // sends; before the fix it sent nothing at all, so the cockpit had nothing to draw while two
541
+ // children ran for minutes.
542
+ if (/\[bg-delegation\]/.test(text)) {
543
+ const subId = `deleg-${sessionId}-1`;
544
+ const snapshot = (rows) => ({
545
+ sessionUpdate: "agent_thought_chunk",
546
+ _meta: { hermes: { kind: "background", tasks: rows } },
547
+ content: { type: "text", text: "" },
548
+ });
549
+ const row = {
550
+ id: subId, kind: "delegation", command: "读一遍 packages/web 的检索链路,写出结论",
551
+ pid: null, status: "running", phase: "running", uptimeSeconds: 95,
552
+ notifyOnComplete: true, taskCount: 2,
553
+ };
554
+ void send(agent._conn, sessionId, snapshot([row])).catch(() => undefined);
555
+ setTimeout(() => {
556
+ void send(agent._conn, sessionId, {
557
+ sessionUpdate: "agent_thought_chunk",
558
+ _meta: { hermes: {
559
+ kind: "completion",
560
+ text: "委派的子任务已完成:读一遍 packages/web 的检索链路,写出结论",
561
+ processId: subId,
562
+ } },
563
+ content: { type: "text", text: "" },
564
+ }).catch(() => undefined);
565
+ }, 1600);
566
+ setTimeout(() => {
567
+ void send(agent._conn, sessionId, snapshot([])).catch(() => undefined);
568
+ }, 2800);
569
+ }
570
+
571
+ // `[bgquiet]` — a SILENT background process: running, no notify, no completion carrier. This is
572
+ // what the agent's own polling looks like, and the cockpit hides it by default (issue #35).
573
+ if (/\[bgquiet\]/.test(text)) {
574
+ const quietId = `bgq-${sessionId}-1`;
575
+ void send(agent._conn, sessionId, {
576
+ sessionUpdate: "agent_thought_chunk",
577
+ _meta: { hermes: { kind: "background", tasks: [{
578
+ id: quietId, command: "tail -f /tmp/agentus-qa.log", pid: 4343,
579
+ status: "running", uptimeSeconds: 5, notifyOnComplete: false,
580
+ }] } },
581
+ content: { type: "text", text: "" },
582
+ }).catch(() => undefined);
583
+ }
584
+
506
585
  // `[table]` — a table fixture for the sorting / full-screen sweep (scripts/qa/table-sweep.mjs).
507
586
  // Numeric AND text cells, deliberately NOT in any sorted order, and a local file link plus a
508
587
  // relative image, so "clicking a header reorders the rows" and "a relative path resolves" are
@@ -688,6 +767,34 @@ const agent = () => ({
688
767
  }
689
768
  }
690
769
 
770
+ // `[tool-many]` — 一个回合改了三个文件(issue #38:回合末尾的「N 个文件变更」)。
771
+ // 三个刻意不同的形状:同一文件**改两刀**(合并成一条、edits=2)、一个 40 行文件只改中间
772
+ // 一行(必须走 hunk 折叠)、一个新文件(oldText 为空 → 不能编造删除行)。
773
+ // 这个 fixture **不发权限请求**,形状就是 accept_edits 那种「不问就写」的回合 —— 也正因此
774
+ // 它同时是「这一块不依赖审批」的证据:从审批路径拿不到任何 diff 的回合,面板照样得有内容。
775
+ if (/\[tool-many\]/.test(text)) {
776
+ const big = Array.from({ length: 40 }, (_, i) => `export const line${i} = ${i};`).join("\n") + "\n";
777
+ const bigEdit = big.replace("export const line20 = 20;", "export const line20 = 'twenty';");
778
+ const calls = [
779
+ { id: `tc-many-1-${sessionId}`, path: "/repo/src/app.ts", oldText: "const x = 1;\nconst y = 2;\n", newText: "const x = 1;\nconst y = 20;\n" },
780
+ { id: `tc-many-2-${sessionId}`, path: "/repo/src/app.ts", oldText: "const x = 1;\nconst y = 20;\n", newText: "const x = 1;\nconst y = 20;\nconst z = 3;\n" },
781
+ { id: `tc-many-3-${sessionId}`, path: "/repo/src/big.ts", oldText: big, newText: bigEdit },
782
+ { id: `tc-many-4-${sessionId}`, path: "/repo/docs/new.md", oldText: "", newText: "# 新文件\n" },
783
+ ];
784
+ for (const c of calls) {
785
+ await send(agent._conn, sessionId, {
786
+ sessionUpdate: "tool_call", toolCallId: c.id,
787
+ title: `Edit file: ${c.path}`, kind: "edit", status: "pending",
788
+ content: [{ type: "diff", path: c.path, oldText: c.oldText, newText: c.newText }],
789
+ rawInput: { tool: "patch", arguments: { path: c.path } },
790
+ });
791
+ await send(agent._conn, sessionId, {
792
+ sessionUpdate: "tool_call_update", toolCallId: c.id,
793
+ status: "completed", rawOutput: `wrote ${c.path}`,
794
+ });
795
+ }
796
+ }
797
+
691
798
  // `[plan-long]` — the same frame with steps long enough to wrap on a phone. The operator's
692
799
  // complaint (2026-10-18) is about exactly this: a step that runs onto a second line on a 360px
693
800
  // screen. Short English steps hide it, so the fixture has to carry the long shape.
@@ -3,6 +3,7 @@
3
3
  // loop, event fan-out with monotonic seq, permission pending-map with timeout,
4
4
  // mode/config plumbing. UI holds zero intelligence (red line D): we only relay.
5
5
  import { spawn } from "node:child_process";
6
+ import fs from "node:fs";
6
7
  import { randomUUID } from "node:crypto";
7
8
  import path from "node:path";
8
9
  import { Readable, Writable } from "node:stream";
@@ -43,11 +44,12 @@ import type {
43
44
  ServerEvent,
44
45
  SessionInfo,
45
46
  SessionModeState,
47
+ StatusNoteItem,
46
48
  TurnTrace,
47
49
  } from "../../../shared/src/index.js";
48
50
  import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, clampTitle, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
49
51
  import { PLAN_GUIDANCE_ACTIVE, PLAN_GUIDANCE_INTRO, PLAN_GUIDANCE_NATIVE } from "../../../shared/src/index.js";
50
- import { PREFS_DEFAULT, type PlanHintSettings } from "../settings.js";
52
+ import { PREFS_DEFAULT, agentSettings, type PlanHintSettings } from "../settings.js";
51
53
 
52
54
  // The cockpit CLI an agent is handed (issue #15). Resolved from THIS module's location, so it points
53
55
  // at the same tree the server runs from — a checkout and an installed package alike (`bin/` is in the
@@ -58,6 +60,16 @@ import { PREFS_DEFAULT, type PlanHintSettings } from "../settings.js";
58
60
  // never see it, so it must not become a second thing users have to learn.)
59
61
  const COCKPIT_BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../../bin/agentus.mjs");
60
62
 
63
+ // #40: a git checkout keeps `bin/agentus.mjs` at mode 100644 (the blob has no exec bit), so the very
64
+ // first thing an agent does with the path we hand it — `$AGENTUS_COCKPIT_BIN session list` — dies with
65
+ // `Permission denied`, and there is no npm shim to fall back on in a checkout (that is the deployment
66
+ // form this project recommends). Fix the mode where we resolve the path: idempotent, best effort (a
67
+ // read-only install stays usable — the agent just has to spell `node <path>`), and it also covers the
68
+ // copies that lose the bit in tar/CI caches. The blob mode is fixed too, so a fresh clone is correct.
69
+ try {
70
+ if ((fs.statSync(COCKPIT_BIN).mode & 0o111) === 0) fs.chmodSync(COCKPIT_BIN, 0o755);
71
+ } catch { /* read-only tree: not fatal, only a worse error message for the agent */ }
72
+
61
73
  // Permission prompts must not hang a session forever (design.md §8-4).
62
74
  // Env-tunable so QA can exercise the timeout path in seconds instead of minutes.
63
75
  const PERMISSION_TIMEOUT_MS = Number(process.env.AGENTUS_PERM_TIMEOUT_MS || 5 * 60_000);
@@ -1828,7 +1840,10 @@ export class SessionManager {
1828
1840
  * reasons, e.g. a title change) then wiped a row the page had just drawn from a `background` frame.
1829
1841
  * An explicit `[]` is the same discipline the three rail buckets use. */
1830
1842
  backgroundSnapshots(): { sessionId: string; tasks: BackgroundTask[] }[] {
1831
- return [...this.#sessions.values()].map((s) => ({ sessionId: s.info.id, tasks: s.background }));
1843
+ return [...this.#sessions.values()].map((s) => ({
1844
+ sessionId: s.info.id,
1845
+ tasks: visibleBackgroundTasks(s.background),
1846
+ }));
1832
1847
  }
1833
1848
 
1834
1849
  #emitSessions(): void {
@@ -1933,7 +1948,7 @@ export class SessionManager {
1933
1948
  const bgTasks = backgroundTasksOf(u);
1934
1949
  if (bgTasks) {
1935
1950
  live.background = bgTasks;
1936
- this.#emit({ t: "background", sessionId: live.info.id, tasks: bgTasks });
1951
+ this.#emit({ t: "background", sessionId: live.info.id, tasks: visibleBackgroundTasks(bgTasks) });
1937
1952
  return;
1938
1953
  }
1939
1954
  const finishedText = completionTextOf(u);
@@ -1976,7 +1991,10 @@ export class SessionManager {
1976
1991
  // 落库(操作者 2026-10-18 选的 A 档):刷新页面、重连、换设备之后,这行还在。
1977
1992
  // 折叠在 store 里做(见 Store.appendNote),因为「压缩开始 → 心跳 → 完成」和每回合的
1978
1993
  // 一次回忆都必须收敛成一行 —— 客户端折只治屏幕噪音,库里照样长心跳。
1979
- const row = this.#store.appendNote(live.info.id, { category, text: statusText });
1994
+ const detail = category === "memory" ? statusItemsOf(u) : undefined;
1995
+ const row = this.#store.appendNote(live.info.id, {
1996
+ category, text: statusText, items: detail?.items, truncated: detail?.truncated,
1997
+ });
1980
1998
  this.#emit({ t: "message", message: row });
1981
1999
  } else {
1982
2000
  // 剩下的(provider 等待的倒计时、租约闲聊)本来就是一秒就作废的呼吸声:只走 live 通道。
@@ -2698,7 +2716,7 @@ function completionTextOf(u: Record<string, unknown>): string | null {
2698
2716
 
2699
2717
  /** The agent's running-process snapshot (#22). Dropped (not defaulted) when `tasks` is not an array:
2700
2718
  * a malformed frame must never blank a row the operator is reading. */
2701
- function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null {
2719
+ export function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null {
2702
2720
  if (hermesKindOf(u) !== "background") return null;
2703
2721
  const raw = hermesPayload(u).tasks;
2704
2722
  if (!Array.isArray(raw)) return null;
@@ -2716,11 +2734,65 @@ function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null
2716
2734
  status,
2717
2735
  uptimeSeconds: typeof r.uptimeSeconds === "number" ? r.uptimeSeconds : undefined,
2718
2736
  exitCode: typeof r.exitCode === "number" ? r.exitCode : null,
2737
+ // Explicit boolean, never `undefined` by omission (#35): the page filters on this, and an
2738
+ // old agent that does not send it must land on `false` (silent) rather than a third state
2739
+ // nobody renders.
2740
+ notifyOnComplete: r.notifyOnComplete === true,
2741
+ // #39: subagents ride the same frame. `kind` defaults to `process` — the page renders the
2742
+ // legacy shape unchanged for every agent that never sends it.
2743
+ kind: r.kind === "delegation" ? "delegation" : "process",
2744
+ phase: typeof r.phase === "string" ? r.phase : undefined,
2745
+ taskCount: typeof r.taskCount === "number" ? r.taskCount : undefined,
2719
2746
  });
2720
2747
  }
2721
2748
  return out;
2722
2749
  }
2723
2750
 
2751
+ /** 默认降噪 (#35): 只留下「完成后会唤醒 agent」的进程。静默进程是 agent 自己轮询的活,
2752
+ * 操作者不需要看见 —— 而唤醒型才是「这个会话还没收工」的强信号。开关打开才回到全量,
2753
+ * 保留 fork 原本「快照是这个进程存在过的唯一见证」的设计价值, 但决定权交给操作者。
2754
+ *
2755
+ * WHY THIS IS A SEPARATE STEP FROM PARSING (measured while running the reap/background sweep):
2756
+ * `live.background` feeds more than rendering — the idle reaper treats a running child as
2757
+ * 「not idle」 and refuses to SIGTERM the agent that owns it. Filtering at parse time made every
2758
+ * silent process invisible to the reaper too, i.e. a slot could be reaped while it still had a
2759
+ * live child (the exact orphan #22 removed). So: store everything, hide at the wire. */
2760
+ export function visibleBackgroundTasks(tasks: BackgroundTask[]): BackgroundTask[] {
2761
+ return agentSettings().showSilentBackground ? tasks : tasks.filter((t) => t.notifyOnComplete === true);
2762
+ }
2763
+
2764
+ /** 一帧状态自带的条目(`_meta.hermes.items`),只有 memory 行用得上 —— issue #37。
2765
+ *
2766
+ * 形状(fork `acp_adapter/events.py:make_status_cb`):与状态句同一条 thought 帧,
2767
+ * `items: [{text, tags?, type?}]`、`truncated` 说明 agent 侧已经截断过。**在这里限一次量**
2768
+ * 是因为这串东西来自 agent 进程、还要落库:provider 已经按 200 字符/条、6K/轮截过,但那是
2769
+ * agent 的承诺,不是我们的保证。上限与前端展开的容量对齐,绝不把一帧 200KB 的 memory 写进 SQLite。
2770
+ */
2771
+ export const NOTE_ITEMS_MAX = 20;
2772
+ export const NOTE_ITEM_CHARS = 300;
2773
+
2774
+ export function statusItemsOf(u: Record<string, unknown>): { items: StatusNoteItem[]; truncated: boolean } | undefined {
2775
+ const raw = hermesPayload(u).items;
2776
+ if (!Array.isArray(raw)) return undefined;
2777
+ const items: StatusNoteItem[] = [];
2778
+ for (const entry of raw) {
2779
+ if (items.length >= NOTE_ITEMS_MAX) break;
2780
+ const text = typeof entry === "object" && entry ? (entry as { text?: unknown }).text : undefined;
2781
+ if (typeof text !== "string" || !text.trim()) continue;
2782
+ const item: StatusNoteItem = { text: text.trim().slice(0, NOTE_ITEM_CHARS) };
2783
+ const tags = (entry as { tags?: unknown }).tags;
2784
+ if (Array.isArray(tags)) {
2785
+ const clean = tags.filter((t): t is string => typeof t === "string" && !!t).slice(0, 8);
2786
+ if (clean.length) item.tags = clean;
2787
+ }
2788
+ const kind = (entry as { type?: unknown }).type;
2789
+ if (typeof kind === "string" && kind) item.type = kind;
2790
+ items.push(item);
2791
+ }
2792
+ if (!items.length) return undefined;
2793
+ return { items, truncated: hermesPayload(u).truncated === true || raw.length > items.length };
2794
+ }
2795
+
2724
2796
  function statusKindOf(u: Record<string, unknown>): string | undefined {
2725
2797
  const meta = u?._meta as { hermes?: { statusKind?: unknown } } | undefined;
2726
2798
  const kind = meta?.hermes?.statusKind;
@@ -56,6 +56,10 @@ export type CockpitSessionRow = {
56
56
  self?: boolean;
57
57
  /** no agent process: the message would have to wake it first */
58
58
  cold: boolean;
59
+ /** you MAY wake this row (issue #41): it is cold, not archived, so `session wake` / `send --wake`
60
+ * can start it. Capability, not permission — whether waking is allowed at all is the operator's
61
+ * 驾驶舱互发消息 switch, reported once in `capabilities.scope.wake`. */
62
+ wakeable: boolean;
59
63
  /** on the operator's 已归档 shelf — OUT of scope for the cockpit channel */
60
64
  archived: boolean;
61
65
  /** last talked to (wall clock) */
@@ -97,7 +101,8 @@ export function capabilities(sendEnabled: boolean): Record<string, unknown> {
97
101
  { name: "session show", args: ["<id>"], returns: "one session in detail, including any approval it is waiting on" },
98
102
  { name: "session messages", args: ["<id>", "--tail N"], returns: `the last N messages (default ${MESSAGES_TAIL_DEFAULT}, max ${MESSAGES_TAIL_MAX}), each cut to ${MESSAGE_CHARS} chars` },
99
103
  { name: "session plan", args: ["<id>"], returns: "the plan object the operator's card renders (title/items/explanation/terminal)" },
100
- { name: "session send", args: ["<id>", "<text…>"], returns: "deliver a message into another session (text after the id, or on stdin)" },
104
+ { name: "session send", args: ["<id>", "<text…>", "--wake"], returns: "deliver a message into another session (text after the id, or on stdin). A COLD target answers cold_target unless you pass --wake" },
105
+ { name: "session wake", args: ["<id>"], returns: "start a cold session's agent process — the same thing the operator's rail does when they open it; the session then accepts send. Gated by the same 驾驶舱互发消息 switch as send" },
101
106
  ],
102
107
  truncation: {
103
108
  messages: `each message's text is cut to ${MESSAGE_CHARS} characters; a cut one ends with … and reports truncated:true`,
@@ -122,6 +127,13 @@ export function capabilities(sendEnabled: boolean): Record<string, unknown> {
122
127
  ? "ENABLED: you may deliver a message into another session in scope"
123
128
  : "DISABLED: the operator has not turned 驾驶舱互发消息 on; send answers code:sends_disabled",
124
129
  send_semantics: "delivered means the target agent RECEIVED the message and started a turn — never that it read it or agreed. Nothing is claimed about a reply.",
130
+ // #41: waking is a step with a COST (a process, its memory, a spot in the warm set), so it is
131
+ // never implicit. `send` to a cold target answers target_cold and names both ways forward; the
132
+ // caller decides whether the conversation is worth a process.
133
+ wake: sendEnabled
134
+ ? "ENABLED (same switch as send): `session wake <id>` starts a cold row, or `session send <id> --wake` wakes then delivers (answer carries delivered_after_wake). Rows carry `wakeable` — a cold, non-archived session. Wake sparingly: each one costs a process."
135
+ : "DISABLED: 驾驶舱互发消息 is off; wake answers code:sends_disabled (wake+send are one intent).",
136
+ cold_targets: "`session send` to a cold session answers code:target_cold — that is NOT a refusal of the conversation, only of the implicit process. Check `wakeable` on the row.",
125
137
  },
126
138
  discipline: [
127
139
  "read before you assert: call `context` instead of trusting your memory of another session",
@@ -148,6 +160,7 @@ export function sessionRow(
148
160
  workspace: info.workspace || info.cwd,
149
161
  createdAt: info.createdAt,...(info.titleBy ? { titleBy: info.titleBy } : {}),
150
162
  cold: Boolean(info.cold),
163
+ wakeable: Boolean(info.cold) && !info.archivedAt,
151
164
  archived: Boolean(info.archivedAt),
152
165
  lastAt: info.lastAt ?? info.createdAt,
153
166
  plan: {
@@ -409,6 +422,7 @@ export const COCKPIT_ERRORS = {
409
422
  sendsDisabled: "sends_disabled",
410
423
  targetBusy: "target_busy",
411
424
  targetCold: "target_cold",
425
+ wakeFailed: "wake_failed",
412
426
  rateLimited: "rate_limited",
413
427
  badRequest: "bad_request",
414
428
  } as const;
@@ -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");