agentus 0.4.2 → 0.4.4

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
@@ -50,11 +50,12 @@ logs in — you just have no backend to start a session on.
50
50
 
51
51
  A plain Hermes install drives sessions; the cockpit's own extras — per-session **thinking
52
52
  depth**, a permission mode that survives a restart, the **context-budget** field, the **status
53
- channel** (memory recall/save hints in the message stream), per-process skill roots, and a
53
+ channel** (memory recall/save hints in the message stream), per-process skill roots, **background-job
54
+ completion wake-ups** (a finished background process comes back as a turn of its own), and a
54
55
  **DeepSeek web backend** (search + extract on the model key) — come from the fork's integration
55
56
  build instead. That is a five-line install:
56
57
  [§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
57
- `agentus/v0.22.0/merge-20261018-1005`, on Hermes `v0.22.0`.
58
+ `agentus/v0.22.0/merge-20261018-1617`, on Hermes `v0.22.0`.
58
59
 
59
60
  The first slot you start will use the Hermes home **your own agent already uses**
60
61
  (`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
package/README.zh-CN.md CHANGED
@@ -44,11 +44,12 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
44
44
  照样能跑、照样能登录 —— 只是没有后端可以开会话。
45
45
 
46
46
  裸装的 Hermes 就能跑会话;驾驶舱自己的那几项能力 —— 按会话切换的**思考深度**、跨重启存活的**权限模式**、
47
- **上下文预算**输入框、把记忆召回 / 存记忆提示送进消息流的 **status 通道**、按进程指定的技能根,
47
+ **上下文预算**输入框、把记忆召回 / 存记忆提示送进消息流的 **status 通道**、按进程指定的技能根、
48
+ **后台任务完成唤醒**(后台进程跑完会自己开一个回合回来),
48
49
  以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
49
50
  装法就五行:
50
51
  [§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
51
- `agentus/v0.22.0/merge-20261018-1005`,基座 Hermes `v0.22.0`。
52
+ `agentus/v0.22.0/merge-20261018-1617`,基座 Hermes `v0.22.0`。
52
53
 
53
54
  你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
54
55
  指定了另一个 —— 见下面的「HERMES_HOME」。
@@ -37,7 +37,9 @@ in the released build — a per-session **thinking depth** (ACP `reasoning_effor
37
37
  mode that survives a restart** and `session/load`, a free-form **context budget** (with
38
38
  256K/512K/768K/1M presets), the **status channel** that carries memory-recall / save hints into the
39
39
  cockpit's message stream, **per-process skill roots** (`HERMES_EXTERNAL_SKILLS_DIRS`, which is how a
40
- slot sees the cockpit's own skill without anything being written into your `~/.hermes`), and a
40
+ slot sees the cockpit's own skill without anything being written into your `~/.hermes`),
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
41
43
  **DeepSeek web backend** (`search` + `extract` on top of `DEEPSEEK_API_KEY` — DeepSeek has no separate
42
44
  search key).
43
45
  They live on the ACP branches of the fork
@@ -53,7 +55,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
53
55
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
54
56
  last segment sorts, so "newest" is a `sort -V`.
55
57
 
56
- **Current integration build: `agentus/v0.22.0/merge-20261018-1005`** — the newest tag on the fork,
58
+ **Current integration build: `agentus/v0.22.0/merge-20261018-1617`** — the newest tag on the fork,
57
59
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
58
60
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
59
61
  ever looks stale.
@@ -34,7 +34,9 @@ brew install node # 或者 https://nodejs.org/en/download
34
34
  (ACP `reasoning_effort`)、**跨重启 / `session/load` 存活的权限模式**、可自由填写的**上下文预算**
35
35
  (带 256K/512K/768K/1M 预设)、把记忆召回 / 存记忆提示送进驾驶舱消息流的 **status 通道**,以及
36
36
  **按进程指定的技能根**(`HERMES_EXTERNAL_SKILLS_DIRS` —— 驾驶舱靠它让槽位看见自己的 skill,
37
- 不往你的 `~/.hermes` 写任何东西),以及 **DeepSeek 的 web 后端**(`search` + `extract` 都复用
37
+ 不往你的 `~/.hermes` 写任何东西)、**后台任务完成唤醒**(`terminal(background=true)` 的进程跑完会被
38
+ 中继成一个新回合,agent 自己接着干,而不是回合就这么结束),以及 **DeepSeek 的 web 后端**
39
+ (`search` + `extract` 都复用
38
40
  `DEEPSEEK_API_KEY` —— DeepSeek 没有单独的搜索密钥)。它们在 fork
39
41
  [`LiangYang666/hermes-agent`](https://github.com/LiangYang666/hermes-agent) 的 ACP 分支上,
40
42
  按基座版本各有一个**集成构建的 tag**,自成命名空间:
@@ -47,7 +49,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
47
49
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
48
50
  可排序,所以「最新」就是一次 `sort -V`。
49
51
 
50
- **当前集成构建:`agentus/v0.22.0/merge-20261018-1005`** —— fork 上最新的那个 tag,基座 Hermes
52
+ **当前集成构建:`agentus/v0.22.0/merge-20261018-1617`** —— fork 上最新的那个 tag,基座 Hermes
51
53
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
52
54
  以它为准。
53
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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": {
@@ -420,14 +420,29 @@ const agent = () => ({
420
420
  // it: an EMPTY thought chunk whose `_meta.hermes` holds the sentence. Sent mid-turn on purpose, so a
421
421
  // sweep can prove the line appears while the turn runs and is gone when it ends, and that NOTHING
422
422
  // of it reaches the stored transcript.
423
- if (/\[status\]/.test(text)) {
424
- setTimeout(() => {
425
- void send(agent._conn, sessionId, {
426
- sessionUpdate: "agent_thought_chunk",
427
- _meta: { hermes: { kind: "status", statusKind: "lifecycle", text: "👁️ Hindsight — recalled 8 memories" } },
428
- content: { type: "text", text: "" },
429
- }).catch(() => undefined);
430
- }, Math.min(slow * 2, 1200));
423
+ // `[status]` / `[status:<kind>]` / `[status:<kind>#<n>]` — the carrier above, with the kind and a
424
+ // repeat count the sweeps need: a compaction is NOT one frame (start, then heartbeats, then done),
425
+ // and the cockpit must fold the heartbeats into the row they belong to instead of stacking rows.
426
+ const statusM = /\[status(?::([a-z_]+))?(?:#(\d+))?\]/.exec(text);
427
+ if (statusM) {
428
+ const kind = statusM[1] ?? "lifecycle";
429
+ const times = Math.min(Number(statusM[2] ?? 1), 8);
430
+ const line = kind === "lifecycle"
431
+ ? "👁️ Hindsight — recalled 8 memories"
432
+ : kind === "compacting"
433
+ ? (times > 1 ? "🗜️ Compacting context — still summarizing earlier conversation..." : "🗜️ Compacting context — summarizing earlier conversation...")
434
+ : kind === "compacted"
435
+ ? "✓ Context compaction complete — continuing turn..."
436
+ : `⚠️ mock ${kind} status`;
437
+ for (let i = 0; i < times; i++) {
438
+ setTimeout(() => {
439
+ void send(agent._conn, sessionId, {
440
+ sessionUpdate: "agent_thought_chunk",
441
+ _meta: { hermes: { kind: "status", statusKind: kind, text: line } },
442
+ content: { type: "text", text: "" },
443
+ }).catch(() => undefined);
444
+ }, Math.min(slow * 2, 1200) + i * 350);
445
+ }
431
446
  }
432
447
 
433
448
  // `[blank]` — the SAME carrier with its `_meta` stripped, i.e. the shape a status line takes when
@@ -443,6 +458,45 @@ const agent = () => ({
443
458
  }, Math.min(slow * 2, 1000));
444
459
  }
445
460
 
461
+ // `[bg]` — a background process that outlives the turn (issue #22), in the three frames the fork's
462
+ // `acp_adapter/background.py` sends: a running snapshot, then the completion carrier, then the
463
+ // snapshot that clears the row. Deliberately AFTER the turn: that is the shape that matters — the
464
+ // session reads ready while the work continues, and the completion has to wake a new turn.
465
+ if (/\[bg\]|\[bg:\d+\]/.test(text)) {
466
+ // `[bg:<seconds>]` holds the row RUNNING that long, which is what the idle-reaper sweep needs: a
467
+ // task still running when the reap clock runs out (scripts/qa/reap-vs-background.mjs). `[bg]`
468
+ // alone keeps the original 1.4 s shape the wake sweeps are built on.
469
+ const hold = Number((/\[bg:(\d+)\]/.exec(text) || [])[1] || 0);
470
+ const runMs = hold ? hold * 1000 : 1400;
471
+ const taskId = `bg-${sessionId}-1`;
472
+ const snapshot = (rows) => ({
473
+ sessionUpdate: "agent_thought_chunk",
474
+ _meta: { hermes: { kind: "background", tasks: rows } },
475
+ content: { type: "text", text: "" },
476
+ });
477
+ const running = [{
478
+ id: taskId, command: "npm run build --workspace packages/web", pid: 4242,
479
+ status: "running", uptimeSeconds: 12,
480
+ }];
481
+ void send(agent._conn, sessionId, snapshot(running)).catch(() => undefined);
482
+ setTimeout(() => {
483
+ void send(agent._conn, sessionId, {
484
+ sessionUpdate: "agent_thought_chunk",
485
+ _meta: { hermes: {
486
+ kind: "completion",
487
+ text: "后台进程已结束(exit 0):npm run build --workspace packages/web",
488
+ exitCode: 0,
489
+ processId: taskId,
490
+ command: "npm run build --workspace packages/web",
491
+ } },
492
+ content: { type: "text", text: "" },
493
+ }).catch(() => undefined);
494
+ }, runMs);
495
+ setTimeout(() => {
496
+ void send(agent._conn, sessionId, snapshot([])).catch(() => undefined);
497
+ }, runMs + 1200);
498
+ }
499
+
446
500
  // `[table]` — a table fixture for the sorting / full-screen sweep (scripts/qa/table-sweep.mjs).
447
501
  // Numeric AND text cells, deliberately NOT in any sorted order, and a local file link plus a
448
502
  // relative image, so "clicking a header reorders the rows" and "a relative path resolves" are
@@ -24,6 +24,7 @@ import type { Store, SessionRow, TitleSrc } from "../store/store.js";
24
24
  import { textOf, withText } from "../store/store.js";
25
25
  import type {
26
26
  AttachmentSummary,
27
+ BackgroundTask,
27
28
  BackendId,
28
29
  ConfigOptionView,
29
30
  PermissionDecision,
@@ -234,6 +235,15 @@ interface LiveSession {
234
235
  /** One log line per agent process the first time a frame is ignored, so a reader of the log can
235
236
  * tell "the cockpit is dropping these on purpose" from "the agent stopped emitting them". */
236
237
  planFramesDropped?: boolean;
238
+ /** Latest background-process snapshot from the agent (issue #22): what is still running once the
239
+ * turn is over. Transient STATE, never a transcript row — 「2 个后台任务在跑」 is true now and false in
240
+ * five minutes, and a stored row would hang a dead process under a conversation from last month. */
241
+ background: BackgroundTask[];
242
+ /** Completion sentences that arrived while the turn was BUSY (#22), in order. Held rather than
243
+ * dropped: the agent is the intended reader, and a completion lost to bad timing means work the
244
+ * operator has to notice and redo by hand. Drained one at a time at turn-end — each opens its own
245
+ * turn, and two at once is a race the agent cannot mediate. */
246
+ pendingCompletions: string[];
237
247
  }
238
248
 
239
249
  type Emitter = (evt: ServerEvent) => void;
@@ -246,12 +256,22 @@ export class SessionManager {
246
256
  * the idle thresholds, because the requirement is that a change in 设置 applies to the very next
247
257
  * turn with no restart and no session to hibernate. */
248
258
  #planHint: () => PlanHintSettings = () => ({ ...PREFS_DEFAULT.planHint });
259
+ /** Whether a finished background process may open a turn by itself (issue #22). A getter, not a
260
+ * value: the operator's switch must apply to the next completion, with no restart and no session
261
+ * to hibernate. Default ON — this is the CLI/gateway behaviour, and the whole point is that the
262
+ * agent takes its own result instead of the operator noticing a stale screen. */
263
+ #completionWake: () => boolean = () => true;
249
264
 
250
265
  constructor(store: Store, emit: Emitter) {
251
266
  this.#store = store;
252
267
  this.#emit = emit;
253
268
  }
254
269
 
270
+ /** Wire the operator's 「后台任务完成唤醒」 switch in (index.ts → settings.agent.completionWake). */
271
+ setCompletionWakeSource(fn: () => boolean): void {
272
+ this.#completionWake = typeof fn === "function" ? fn : () => true;
273
+ }
274
+
255
275
  /** Wire the cockpit's plan-hint setting in. Optional on purpose: with no source wired the
256
276
  * manager behaves exactly as it did before the setting existed (on, 仅首条). */
257
277
  setPlanHintSource(read: () => PlanHintSettings): void {
@@ -482,6 +502,7 @@ export class SessionManager {
482
502
  createdAt: now, modes: null, configOptions: [], commands: [],
483
503
  },
484
504
  child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
505
+ background: [], pendingCompletions: [],
485
506
  caps: { fork: false, load: false, image: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
486
507
  planToken: randomUUID(),
487
508
  cockpitToken,
@@ -666,6 +687,7 @@ export class SessionManager {
666
687
  commands: normCommands(row.commands),
667
688
  },
668
689
  child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
690
+ background: [], pendingCompletions: [],
669
691
  caps: { fork: false, load: false, image: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
670
692
  planToken: randomUUID(),
671
693
  cockpitToken,
@@ -1054,9 +1076,54 @@ export class SessionManager {
1054
1076
  s.info.status = "ready";
1055
1077
  this.#updateSession(s);
1056
1078
  }
1079
+ // A completion that arrived mid-turn was held, not dropped (#22). Hand the first one back now —
1080
+ // scheduled rather than awaited so this prompt's own teardown finishes first, and only one at a
1081
+ // time because each opens its own turn.
1082
+ this.#drainHeldCompletion(s);
1083
+ }
1084
+ }
1085
+
1086
+ /** Wake the agent for one finished background process, or hold it until the turn ends.
1087
+ *
1088
+ * The gateway does exactly this (``_inject_watch_notification``): the completion is *for the agent* —
1089
+ * its own tool text says "you will be notified on exit — do more work instead of waiting" — so the
1090
+ * cockpit's job is to deliver it as a turn, not to decorate a screen. Two refusals are deliberate:
1091
+ * with the operator's switch off we say so in the log and drop it (that is the switch's meaning), and
1092
+ * while a turn is running we hold it, because a prompt would otherwise interrupt work the operator
1093
+ * started. */
1094
+ #queueOrWakeCompletion(s: LiveSession, text: string): void {
1095
+ if (!text) return;
1096
+ if (!this.#completionWake()) {
1097
+ console.log(`[agentus] background completion dropped for ${s.info.id}: 唤醒关闭`);
1098
+ return;
1099
+ }
1100
+ if (s.busy) {
1101
+ s.pendingCompletions.push(text);
1102
+ return;
1103
+ }
1104
+ void this.#wakeWithCompletion(s, text);
1105
+ }
1106
+
1107
+ async #wakeWithCompletion(s: LiveSession, text: string): Promise<void> {
1108
+ try {
1109
+ // `origin` so the page labels the bubble as a background notice instead of presenting it as
1110
+ // something the operator typed — the same mechanism the cockpit's cross-session `send` uses.
1111
+ await this.prompt(s.info.id, text, [], { origin: { sessionId: "background", title: "后台任务完成" } });
1112
+ } catch (err) {
1113
+ // Lost a race for the floor (an operator prompt landed first): keep it for the next turn end
1114
+ // instead of throwing away the agent's own result.
1115
+ s.pendingCompletions.unshift(text);
1116
+ console.log(`[agentus] completion held for ${s.info.id}: ${errMessage(err)}`);
1057
1117
  }
1058
1118
  }
1059
1119
 
1120
+ #drainHeldCompletion(s: LiveSession): void {
1121
+ if (!s.pendingCompletions.length || s.busy) return;
1122
+ const next = s.pendingCompletions.shift();
1123
+ if (!next) return;
1124
+ setTimeout(() => void this.#wakeWithCompletion(s, next), 0).unref?.();
1125
+ }
1126
+
1060
1127
  async cancel(sessionId: string): Promise<void> {
1061
1128
  const s = this.#need(sessionId);
1062
1129
  if (!s.conn || !s.info.acpSessionId) return;
@@ -1646,6 +1713,14 @@ export class SessionManager {
1646
1713
  if (s.busy || s.pendingPermissions.size) continue;
1647
1714
  if (s.info.status === "starting" || s.info.status === "running") continue;
1648
1715
  if (opts.watched?.(s.info.id)) continue;
1716
+ // A session whose agent still OWNS A RUNNING BACKGROUND PROCESS is not idle, whatever the clock
1717
+ // says. Reaping it would SIGTERM the only process that can report that result (`#stop` kills the
1718
+ // agent, not the process group), so the child would be left orphaned and unread — the exact
1719
+ // silent failure #22 exists to remove: 「会话看起来结束了,活儿其实还在跑」. Reported by the
1720
+ // operator as a question (2026-10-18: 「有后台进程的话,它还会被空闲回收吗?」) — it could.
1721
+ // It is still bounded: only the long warm deadline (`warmTtlMs`) may reclaim it, so a runaway
1722
+ // process cannot pin a slot forever.
1723
+ const hasBackground = s.background.some((t) => t.status === "running");
1649
1724
  // The clock is the LATER of two things: the last MESSAGE this session has (store time, which
1650
1725
  // survives restarts) and the last time the operator actually USED the slot (`touchedAt` — the
1651
1726
  // click that respawned it, or the page reporting it as the one on screen). Reading only the
@@ -1661,6 +1736,13 @@ export class SessionManager {
1661
1736
  console.log(
1662
1737
  `[agentus] warm slot expired: ${s.info.id} (${fmt(age)} untouched, warm ttl ${fmt(warmTtlMs)})`,
1663
1738
  );
1739
+ } else if (hasBackground) {
1740
+ // Idle time is not the whole question: the agent still OWNS a running child, so the short
1741
+ // clock does not apply — only the long deadline can reclaim it.
1742
+ if (!(warmTtlMs > 0) || age < warmTtlMs) continue;
1743
+ console.log(
1744
+ `[agentus] idle reap: ${s.info.id} (background task still running after ${fmt(age)}, hard limit ${fmt(warmTtlMs)})`,
1745
+ );
1664
1746
  } else {
1665
1747
  if (!(idleMs > 0) || age < idleMs) continue;
1666
1748
  console.log(
@@ -1684,6 +1766,18 @@ export class SessionManager {
1684
1766
 
1685
1767
  /** The list every client needs to be current: the rows PLUS what is already waiting for an
1686
1768
  * answer. Sent on connect too, which is what makes a refreshed page show a live approval. */
1769
+ /** Every live session's current background snapshot (issue #22), for a page that connects AFTER the
1770
+ * frame that carried it.
1771
+ *
1772
+ * EMPTY ONES ARE INCLUDED (`tasks: []`), and that is not noise: the page clears a row when a session
1773
+ * is absent from this list AND treats a present-but-empty one as 「nothing running」. Omitting empties
1774
+ * made the two cases indistinguishable — every `sessions` frame (which is emitted for unrelated
1775
+ * reasons, e.g. a title change) then wiped a row the page had just drawn from a `background` frame.
1776
+ * An explicit `[]` is the same discipline the three rail buckets use. */
1777
+ backgroundSnapshots(): { sessionId: string; tasks: BackgroundTask[] }[] {
1778
+ return [...this.#sessions.values()].map((s) => ({ sessionId: s.info.id, tasks: s.background }));
1779
+ }
1780
+
1687
1781
  #emitSessions(): void {
1688
1782
  // ALL THREE buckets, every time. This is the SAME event the HTTP path builds (`sessionsEvent()`
1689
1783
  // in index.ts), and the client replaces its lists wholesale from whatever arrives — so emitting
@@ -1697,6 +1791,7 @@ export class SessionManager {
1697
1791
  cold: this.cold(),
1698
1792
  archived: this.archived(),
1699
1793
  pending: this.pendingPermissions(),
1794
+ background: this.backgroundSnapshots(),
1700
1795
  });
1701
1796
  }
1702
1797
 
@@ -1775,9 +1870,36 @@ export class SessionManager {
1775
1870
  // extension door it does leave open. Honoured here rather than in the web, because the decision that
1776
1871
  // matters is 「do not persist」: a recall count belongs to the turn that is running, and writing it
1777
1872
  // into the transcript would put 「recalled 8 memories」 above a conversation from last month.
1873
+ // Background work (issue #22): the same `_meta.hermes` door as the status line carries two more
1874
+ // kinds. `background` is the agent's running snapshot — STATE, replaced wholesale, shown only while
1875
+ // the turn is over and work continues; `completion` is a finished process asking to be read, which
1876
+ // the agent must get as a TURN (ACP has no server->agent wake, so the prompt is the wake). Anything
1877
+ // else is still ignored silently, which is what lets the fork and this server ship independently.
1878
+ const bgTasks = backgroundTasksOf(u);
1879
+ if (bgTasks) {
1880
+ live.background = bgTasks;
1881
+ this.#emit({ t: "background", sessionId: live.info.id, tasks: bgTasks });
1882
+ return;
1883
+ }
1884
+ const finishedText = completionTextOf(u);
1885
+ if (finishedText) {
1886
+ this.#queueOrWakeCompletion(live, finishedText);
1887
+ return;
1888
+ }
1778
1889
  const statusText = statusLineOf(u);
1779
1890
  if (statusText) {
1780
- this.#emit({ t: "status", sessionId: live.info.id, text: statusText, statusKind: statusKindOf(u) });
1891
+ const statusKind = statusKindOf(u);
1892
+ const category = noteCategoryOf(statusText, statusKind);
1893
+ if (category) {
1894
+ // 落库(操作者 2026-10-18 选的 A 档):刷新页面、重连、换设备之后,这行还在。
1895
+ // 折叠在 store 里做(见 Store.appendNote),因为「压缩开始 → 心跳 → 完成」和每回合的
1896
+ // 一次回忆都必须收敛成一行 —— 客户端折只治屏幕噪音,库里照样长心跳。
1897
+ const row = this.#store.appendNote(live.info.id, { category, text: statusText });
1898
+ this.#emit({ t: "message", message: row });
1899
+ } else {
1900
+ // 剩下的(provider 等待的倒计时、租约闲聊)本来就是一秒就作废的呼吸声:只走 live 通道。
1901
+ this.#emit({ t: "status", sessionId: live.info.id, text: statusText, statusKind });
1902
+ }
1781
1903
  return;
1782
1904
  }
1783
1905
  let msg: Parameters<Store["appendMessage"]>[0] | null = null;
@@ -1966,6 +2088,13 @@ export class SessionManager {
1966
2088
  if (live.replacing) return;
1967
2089
  this.#dropPending(live);
1968
2090
  live.busy = false;
2091
+ // The child that owned those background processes is gone with it; leaving the rows would draw
2092
+ // 「1 个后台任务在跑」 under a session whose agent is dead (#22).
2093
+ if (live.background.length) {
2094
+ live.background = [];
2095
+ this.#emit({ t: "background", sessionId: live.info.id, tasks: [] });
2096
+ }
2097
+ live.pendingCompletions = [];
1969
2098
  live.info.status = "error";
1970
2099
  live.info.lastError = `agent process exited (code=${code} sig=${sig})` + stderrTail(live);
1971
2100
  const row = this.#store.getSession(live.info.id);
@@ -2417,12 +2546,82 @@ function statusLineOf(u: Record<string, unknown>): string | null {
2417
2546
  return text || null;
2418
2547
  }
2419
2548
 
2549
+ /** The hermes extension kind on this frame, or null for an ordinary ACP update.
2550
+ *
2551
+ * One reader for every kind, so adding one (completion/background, #22) does not mean another
2552
+ * defensive `_meta` walk — and so an unknown kind is still ignored SILENTLY, which is what lets the
2553
+ * fork and the server ship independently.
2554
+ */
2555
+ function hermesKindOf(u: Record<string, unknown>): string | null {
2556
+ const meta = u?._meta as { hermes?: { kind?: unknown } } | undefined;
2557
+ const kind = meta?.hermes?.kind;
2558
+ return typeof kind === "string" && kind ? kind : null;
2559
+ }
2560
+
2561
+ function hermesPayload(u: Record<string, unknown>): Record<string, unknown> {
2562
+ const meta = u?._meta as { hermes?: Record<string, unknown> } | undefined;
2563
+ return meta?.hermes ?? {};
2564
+ }
2565
+
2566
+ /** The completion sentence a finished background process produced (#22), or null. */
2567
+ function completionTextOf(u: Record<string, unknown>): string | null {
2568
+ if (hermesKindOf(u) !== "completion") return null;
2569
+ const text = hermesPayload(u).text;
2570
+ return typeof text === "string" ? text.trim() : null;
2571
+ }
2572
+
2573
+ /** The agent's running-process snapshot (#22). Dropped (not defaulted) when `tasks` is not an array:
2574
+ * a malformed frame must never blank a row the operator is reading. */
2575
+ function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null {
2576
+ if (hermesKindOf(u) !== "background") return null;
2577
+ const raw = hermesPayload(u).tasks;
2578
+ if (!Array.isArray(raw)) return null;
2579
+ const out: BackgroundTask[] = [];
2580
+ for (const t of raw) {
2581
+ if (!t || typeof t !== "object") continue;
2582
+ const r = t as Record<string, unknown>;
2583
+ const id = typeof r.id === "string" ? r.id : "";
2584
+ if (!id) continue;
2585
+ const status = r.status === "exited" ? "exited" : "running";
2586
+ out.push({
2587
+ id,
2588
+ command: typeof r.command === "string" ? r.command : "",
2589
+ pid: typeof r.pid === "number" ? r.pid : null,
2590
+ status,
2591
+ uptimeSeconds: typeof r.uptimeSeconds === "number" ? r.uptimeSeconds : undefined,
2592
+ exitCode: typeof r.exitCode === "number" ? r.exitCode : null,
2593
+ });
2594
+ }
2595
+ return out;
2596
+ }
2597
+
2420
2598
  function statusKindOf(u: Record<string, unknown>): string | undefined {
2421
2599
  const meta = u?._meta as { hermes?: { statusKind?: unknown } } | undefined;
2422
2600
  const kind = meta?.hermes?.statusKind;
2423
2601
  return typeof kind === "string" && kind ? kind : undefined;
2424
2602
  }
2425
2603
 
2604
+ /** 哪些状态行值得留档(操作者 2026-10-18 选的是 A 档)。
2605
+ *
2606
+ * Hermes 发来的「状态行」是一根混在一起的管子:`status_output.py:102` 把所有东西都标成
2607
+ * `lifecycle`,只有警告是 `warn`。所以分类只能看**文本**,而这些字符串是从 fork 里抄来的
2608
+ * 真实文案(`conversation_compression.py` 的 `🗜️ Compacting context` / `✓ Context compaction
2609
+ * complete` / `📦 Pre-API compression:`,`memory_manager.describe_recall` 的 `recalled N memories`):
2610
+ *
2611
+ * · memory —— 「这一轮记忆召回了什么」,每回合都会发生,事后有意义;
2612
+ * · compaction —— 「上下文压过」,一行就够(记住它压缩过,不需要记住它压了 12 次心跳);
2613
+ * · warn —— 稀有,且几乎总是"要人看一眼"的东西。
2614
+ *
2615
+ * provider 等待(`⏳ waiting on … 15s waiting for the first stream chunk`,十来秒一条倒计时)和
2616
+ * 「Session is free; loading the latest transcript...」这类租约闲聊返回 null:它们描述的是"此刻
2617
+ * 网络的呼吸",一秒后就作废,落库只会把会话记录变成心跳日志。 */
2618
+ export function noteCategoryOf(text: string, statusKind?: string): "memory" | "compaction" | "warn" | null {
2619
+ if (statusKind === "warn") return "warn";
2620
+ if (/Compacting context|Context compaction complete|compression:|compacting ~/i.test(text)) return "compaction";
2621
+ if (/recalled (?:relevant memory|1 memory|\d+ memories)/i.test(text)) return "memory";
2622
+ return null;
2623
+ }
2624
+
2426
2625
  function buildPromptBlocks(text: string, attachments: PromptAttachment[]): unknown[] {
2427
2626
  const blocks: unknown[] = [];
2428
2627
  const body = text.trim();
@@ -2,7 +2,7 @@
2
2
  // The browser never talks to CLI subprocesses directly (design.md §1).
3
3
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4
4
  import type { Duplex } from "node:stream";
5
- import { createHash, randomUUID } from "node:crypto";
5
+ import { createHash, randomUUID, X509Certificate } from "node:crypto";
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { homedir } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
@@ -101,6 +101,19 @@ const APK_FILE = process.env.AGENTUS_APK
101
101
  path.join(DATA_DIR, "agentus-companion.apk"),
102
102
  ].find((p) => fs.existsSync(p))
103
103
  ?? path.resolve(HERE, "../../../android/artifacts/agentus-companion.apk");
104
+ // The root CA this instance's HTTPS is signed by, served to logged-in operators so a phone can
105
+ // trust the tunnel's self-signed cert once instead of tapping through a warning forever. Same
106
+ // reasoning as the APK: an artifact you install on a device belongs behind the operator login, and
107
+ // a page that invites you to install a TRUST ANCHOR especially so. Public half only — `ca.key` is
108
+ // never read here, and the file this points at holds the certificate alone.
109
+ // Candidates: an explicit pin (a Caddy/safe-nat root lives outside any checkout), then the data
110
+ // dir for a self-contained install, then the Caddy default. Absent is not fatal: the card says so.
111
+ const CA_FILE = process.env.AGENTUS_CA_FILE
112
+ ?? [
113
+ path.join(DATA_DIR, "tls", "ca.pem"),
114
+ path.join(homedir(), ".config", "caddy", "tls", "ca.pem"),
115
+ ].find((p) => fs.existsSync(p))
116
+ ?? path.join(DATA_DIR, "tls", "ca.pem");
104
117
  // `agentus --where` (bin/agentus.mjs) asks the ONE place that decides these paths to say
105
118
  // what it decided, then exits — before any listener, store or subprocess exists. Print and
106
119
  // exit here rather than re-deriving the same rules in the CLI: "where is my data" must not
@@ -209,6 +222,9 @@ const sessionsEvent = (): ServerEvent => ({
209
222
  cold: mgr.cold(),
210
223
  archived: mgr.archived(),
211
224
  pending: mgr.pendingPermissions(),
225
+ // Background snapshots (issue #22) ride this frame for the same reason pending approvals do: they are
226
+ // STATE, and a page that connects or refreshes after the agent last spoke still has to be current.
227
+ background: mgr.backgroundSnapshots(),
212
228
  });
213
229
 
214
230
  // 工作播报 (issue #16): one long-lived observer per watched session, in its own scratch home under
@@ -383,6 +399,10 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
383
399
  // The plan-tool hint: read per prompt, so the 设置 switch applies to the next turn with nothing
384
400
  // to restart (the session's own plan card is what it fills).
385
401
  mgr.setPlanHintSource(planHintSettings);
402
+ // Issue #22: a finished background process may open a turn by itself. Default on (the CLI/gateway
403
+ // behaviour); the switch exists because a synthetic turn spends tokens the operator may not want
404
+ // spent at 3am — and it is read live, so flipping it applies to the next completion.
405
+ mgr.setCompletionWakeSource(() => agentSettings().completionWake);
386
406
  mgr.startIdleReaper({
387
407
  idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
388
408
  warmSlots: () => envWarm ?? agentSettings().warmSlots,
@@ -753,6 +773,41 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
753
773
  return res.end("Agentus: authentication required\n");
754
774
  }
755
775
 
776
+ // ---- the root CA, behind the operator login too -------------------------------------
777
+ // `GET /api/tls` what the cert is (subject, validity, SHA-256 fingerprint) so the
778
+ // settings card can show it before you download anything;
779
+ // `GET /api/tls/ca.crt` the DER the phone/Mac installs. DER + `application/x-x509-ca-cert`
780
+ // is what makes iOS/macOS open the *profile install* flow on a plain
781
+ // tap — serve it as text/plain or octet-stream and iOS just shows text.
782
+ // Nothing here reads a key: this is the certificate, which is public material by design.
783
+ if (url.pathname === "/api/tls" || url.pathname === "/api/tls/ca.crt") {
784
+ if (!auth.authenticate(req.headers, url)) return send(res, 401, { error: "login required" });
785
+ if (!fs.existsSync(CA_FILE)) return send(res, 404, { error: "no ca configured", path: CA_FILE });
786
+ let cert: X509Certificate;
787
+ try { cert = new X509Certificate(fs.readFileSync(CA_FILE)); }
788
+ catch (e) { return send(res, 500, { error: `ca is not a certificate: ${(e as Error).message}` }); }
789
+ if (url.pathname === "/api/tls/ca.crt") {
790
+ res.writeHead(200, {
791
+ "content-type": "application/x-x509-ca-cert",
792
+ "content-length": String(cert.raw.length),
793
+ "content-disposition": 'attachment; filename="agentus-ca.crt"',
794
+ "cache-control": "no-store",
795
+ });
796
+ return res.end(cert.raw);
797
+ }
798
+ return send(res, 200, {
799
+ ok: true,
800
+ hasCA: true,
801
+ subject: cert.subject.replace(/\n/g, ", "),
802
+ issuer: cert.issuer.replace(/\n/g, ", "),
803
+ notBefore: cert.validFrom,
804
+ notAfter: cert.validTo,
805
+ fingerprint256: cert.fingerprint256,
806
+ bytes: cert.raw.length,
807
+ selfSigned: cert.subject === cert.issuer,
808
+ });
809
+ }
810
+
756
811
  // ---- the phone's onboarding page + the APK, both behind the operator login ----------
757
812
  // The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
758
813
  // sent to the login screen (the SPA at /) and comes back with a session cookie.