agentus 0.4.2 → 0.4.3

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.3",
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": {
@@ -443,6 +443,45 @@ const agent = () => ({
443
443
  }, Math.min(slow * 2, 1000));
444
444
  }
445
445
 
446
+ // `[bg]` — a background process that outlives the turn (issue #22), in the three frames the fork's
447
+ // `acp_adapter/background.py` sends: a running snapshot, then the completion carrier, then the
448
+ // snapshot that clears the row. Deliberately AFTER the turn: that is the shape that matters — the
449
+ // session reads ready while the work continues, and the completion has to wake a new turn.
450
+ if (/\[bg\]|\[bg:\d+\]/.test(text)) {
451
+ // `[bg:<seconds>]` holds the row RUNNING that long, which is what the idle-reaper sweep needs: a
452
+ // task still running when the reap clock runs out (scripts/qa/reap-vs-background.mjs). `[bg]`
453
+ // alone keeps the original 1.4 s shape the wake sweeps are built on.
454
+ const hold = Number((/\[bg:(\d+)\]/.exec(text) || [])[1] || 0);
455
+ const runMs = hold ? hold * 1000 : 1400;
456
+ const taskId = `bg-${sessionId}-1`;
457
+ const snapshot = (rows) => ({
458
+ sessionUpdate: "agent_thought_chunk",
459
+ _meta: { hermes: { kind: "background", tasks: rows } },
460
+ content: { type: "text", text: "" },
461
+ });
462
+ const running = [{
463
+ id: taskId, command: "npm run build --workspace packages/web", pid: 4242,
464
+ status: "running", uptimeSeconds: 12,
465
+ }];
466
+ void send(agent._conn, sessionId, snapshot(running)).catch(() => undefined);
467
+ setTimeout(() => {
468
+ void send(agent._conn, sessionId, {
469
+ sessionUpdate: "agent_thought_chunk",
470
+ _meta: { hermes: {
471
+ kind: "completion",
472
+ text: "后台进程已结束(exit 0):npm run build --workspace packages/web",
473
+ exitCode: 0,
474
+ processId: taskId,
475
+ command: "npm run build --workspace packages/web",
476
+ } },
477
+ content: { type: "text", text: "" },
478
+ }).catch(() => undefined);
479
+ }, runMs);
480
+ setTimeout(() => {
481
+ void send(agent._conn, sessionId, snapshot([])).catch(() => undefined);
482
+ }, runMs + 1200);
483
+ }
484
+
446
485
  // `[table]` — a table fixture for the sorting / full-screen sweep (scripts/qa/table-sweep.mjs).
447
486
  // Numeric AND text cells, deliberately NOT in any sorted order, and a local file link plus a
448
487
  // 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,7 +1076,52 @@ 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;
1057
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)}`);
1117
+ }
1118
+ }
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?.();
1058
1125
  }
1059
1126
 
1060
1127
  async cancel(sessionId: string): Promise<void> {
@@ -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,6 +1870,22 @@ 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
1891
  this.#emit({ t: "status", sessionId: live.info.id, text: statusText, statusKind: statusKindOf(u) });
@@ -1966,6 +2077,13 @@ export class SessionManager {
1966
2077
  if (live.replacing) return;
1967
2078
  this.#dropPending(live);
1968
2079
  live.busy = false;
2080
+ // The child that owned those background processes is gone with it; leaving the rows would draw
2081
+ // 「1 个后台任务在跑」 under a session whose agent is dead (#22).
2082
+ if (live.background.length) {
2083
+ live.background = [];
2084
+ this.#emit({ t: "background", sessionId: live.info.id, tasks: [] });
2085
+ }
2086
+ live.pendingCompletions = [];
1969
2087
  live.info.status = "error";
1970
2088
  live.info.lastError = `agent process exited (code=${code} sig=${sig})` + stderrTail(live);
1971
2089
  const row = this.#store.getSession(live.info.id);
@@ -2417,6 +2535,55 @@ function statusLineOf(u: Record<string, unknown>): string | null {
2417
2535
  return text || null;
2418
2536
  }
2419
2537
 
2538
+ /** The hermes extension kind on this frame, or null for an ordinary ACP update.
2539
+ *
2540
+ * One reader for every kind, so adding one (completion/background, #22) does not mean another
2541
+ * defensive `_meta` walk — and so an unknown kind is still ignored SILENTLY, which is what lets the
2542
+ * fork and the server ship independently.
2543
+ */
2544
+ function hermesKindOf(u: Record<string, unknown>): string | null {
2545
+ const meta = u?._meta as { hermes?: { kind?: unknown } } | undefined;
2546
+ const kind = meta?.hermes?.kind;
2547
+ return typeof kind === "string" && kind ? kind : null;
2548
+ }
2549
+
2550
+ function hermesPayload(u: Record<string, unknown>): Record<string, unknown> {
2551
+ const meta = u?._meta as { hermes?: Record<string, unknown> } | undefined;
2552
+ return meta?.hermes ?? {};
2553
+ }
2554
+
2555
+ /** The completion sentence a finished background process produced (#22), or null. */
2556
+ function completionTextOf(u: Record<string, unknown>): string | null {
2557
+ if (hermesKindOf(u) !== "completion") return null;
2558
+ const text = hermesPayload(u).text;
2559
+ return typeof text === "string" ? text.trim() : null;
2560
+ }
2561
+
2562
+ /** The agent's running-process snapshot (#22). Dropped (not defaulted) when `tasks` is not an array:
2563
+ * a malformed frame must never blank a row the operator is reading. */
2564
+ function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[] | null {
2565
+ if (hermesKindOf(u) !== "background") return null;
2566
+ const raw = hermesPayload(u).tasks;
2567
+ if (!Array.isArray(raw)) return null;
2568
+ const out: BackgroundTask[] = [];
2569
+ for (const t of raw) {
2570
+ if (!t || typeof t !== "object") continue;
2571
+ const r = t as Record<string, unknown>;
2572
+ const id = typeof r.id === "string" ? r.id : "";
2573
+ if (!id) continue;
2574
+ const status = r.status === "exited" ? "exited" : "running";
2575
+ out.push({
2576
+ id,
2577
+ command: typeof r.command === "string" ? r.command : "",
2578
+ pid: typeof r.pid === "number" ? r.pid : null,
2579
+ status,
2580
+ uptimeSeconds: typeof r.uptimeSeconds === "number" ? r.uptimeSeconds : undefined,
2581
+ exitCode: typeof r.exitCode === "number" ? r.exitCode : null,
2582
+ });
2583
+ }
2584
+ return out;
2585
+ }
2586
+
2420
2587
  function statusKindOf(u: Record<string, unknown>): string | undefined {
2421
2588
  const meta = u?._meta as { hermes?: { statusKind?: unknown } } | undefined;
2422
2589
  const kind = meta?.hermes?.statusKind;
@@ -209,6 +209,9 @@ const sessionsEvent = (): ServerEvent => ({
209
209
  cold: mgr.cold(),
210
210
  archived: mgr.archived(),
211
211
  pending: mgr.pendingPermissions(),
212
+ // Background snapshots (issue #22) ride this frame for the same reason pending approvals do: they are
213
+ // STATE, and a page that connects or refreshes after the agent last spoke still has to be current.
214
+ background: mgr.backgroundSnapshots(),
212
215
  });
213
216
 
214
217
  // 工作播报 (issue #16): one long-lived observer per watched session, in its own scratch home under
@@ -383,6 +386,10 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
383
386
  // The plan-tool hint: read per prompt, so the 设置 switch applies to the next turn with nothing
384
387
  // to restart (the session's own plan card is what it fills).
385
388
  mgr.setPlanHintSource(planHintSettings);
389
+ // Issue #22: a finished background process may open a turn by itself. Default on (the CLI/gateway
390
+ // behaviour); the switch exists because a synthetic turn spends tokens the operator may not want
391
+ // spent at 3am — and it is read live, so flipping it applies to the next completion.
392
+ mgr.setCompletionWakeSource(() => agentSettings().completionWake);
386
393
  mgr.startIdleReaper({
387
394
  idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
388
395
  warmSlots: () => envWarm ?? agentSettings().warmSlots,
@@ -185,9 +185,15 @@ export interface AgentSettings {
185
185
  * (`context`/`messages`/`plan`) is NOT gated: it cannot change anything, and it is what the
186
186
  * 工作播报 observer runs on. */
187
187
  cockpitSend: boolean;
188
+ /** Let a finished background process wake the agent with its own result (issue #22). ON by default:
189
+ * this is what the CLI and the gateway already do, and the failure it prevents is the silent one —
190
+ * a session that looks finished while a build is still running, whose result nobody ever reads. */
191
+ completionWake: boolean;
188
192
  }
189
193
 
190
- export const AGENT_DEFAULT: AgentSettings = { idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false };
194
+ export const AGENT_DEFAULT: AgentSettings = {
195
+ idleKillMin: 5, warmSlots: 5, warmTtlHours: 24, cockpitSend: false, completionWake: true,
196
+ };
191
197
 
192
198
  /** The numeric half of `AgentSettings` — the keys the range-checked helpers may write. */
193
199
  type AgentNumberKey = "idleKillMin" | "warmSlots" | "warmTtlHours";
@@ -419,6 +425,7 @@ function pickAgent(raw: unknown): AgentSettings {
419
425
  take("warmSlots", AGENT_WARM_RANGE);
420
426
  take("warmTtlHours", AGENT_WARM_TTL_RANGE);
421
427
  if (typeof r.cockpitSend === "boolean") out.cockpitSend = r.cockpitSend;
428
+ if (typeof r.completionWake === "boolean") out.completionWake = r.completionWake;
422
429
  }
423
430
  return out;
424
431
  }
@@ -718,6 +725,10 @@ export function saveAgent(patch: Record<string, unknown>): Settings {
718
725
  if (typeof patch.cockpitSend !== "boolean") throw new Error("invalid cockpitSend: expected true or false");
719
726
  a.cockpitSend = patch.cockpitSend;
720
727
  }
728
+ if ("completionWake" in patch) {
729
+ if (typeof patch.completionWake !== "boolean") throw new Error("invalid completionWake: expected true or false");
730
+ a.completionWake = patch.completionWake;
731
+ }
721
732
  cache = { ...cache, agent: a, updatedAt: Date.now() };
722
733
  persist();
723
734
  return cache;
@@ -169,7 +169,7 @@ export interface PermissionDiff {
169
169
  // ---- WS envelope (server -> browser) ----
170
170
  export type ServerEvent =
171
171
  | { t: "hello"; clientId: string; resumed: boolean }
172
- | { t: "sessions"; sessions: SessionInfo[]; /** rows on disk with NO process and no archive flag — they belong in 工作空间 beside the live ones (a crashed slot must not look archived, and must not vanish from the rail either) */ cold?: SessionInfo[]; /** rows the operator explicitly archived (archivedAt set) — the 已归档 section, whatever their process state */ archived?: SessionInfo[]; /** requests already waiting for an answer, so a page that connects or refreshes learns about them (they are STATE; the events happened before it existed) */ pending?: PermissionRequestView[] }
172
+ | { t: "sessions"; sessions: SessionInfo[]; /** current background snapshots, for a page that connects AFTER the frame that carried them */ background?: { sessionId: string; tasks: BackgroundTask[] }[]; /** rows on disk with NO process and no archive flag — they belong in 工作空间 beside the live ones (a crashed slot must not look archived, and must not vanish from the rail either) */ cold?: SessionInfo[]; /** rows the operator explicitly archived (archivedAt set) — the 已归档 section, whatever their process state */ archived?: SessionInfo[]; /** requests already waiting for an answer, so a page that connects or refreshes learns about them (they are STATE; the events happened before it existed) */ pending?: PermissionRequestView[] }
173
173
  | { t: "session"; session: SessionInfo }
174
174
  | { t: "messages"; sessionId: string; messages: StoredMessage[]; hasMore: boolean; partial?: boolean }
175
175
  | { t: "message"; message: StoredMessage; /** text to APPEND for a streamed block that grew (absent
@@ -214,6 +214,15 @@ export type ServerEvent =
214
214
  * conversation from last week). Carrier on the ACP side: an empty thought chunk tagged
215
215
  * `_meta.hermes.kind = "status"`. */
216
216
  | { t: "status"; sessionId: string; text: string; statusKind?: string }
217
+ /** Background processes this session still has (issue #22) — the ACP equivalent of the desktop's
218
+ * status stack, and the reason a turn can end while work goes on.
219
+ *
220
+ * SNAPSHOT, replaced wholesale, never stored: the agent re-sends the whole list whenever it changes,
221
+ * and the last one wins. A row is 「running」 or 「exited」 (with `exitCode`) — the exit is reported
222
+ * once, because a process without `notify_on_complete` publishes no event and this frame is the only
223
+ * witness it ever ended. Empty `tasks` = nothing runs; the page shows nothing.
224
+ * Carrier on the ACP side: an empty thought chunk tagged `_meta.hermes.kind = "background"`. */
225
+ | { t: "background"; sessionId: string; tasks: BackgroundTask[] }
217
226
  /** The session's plan object — the TRUTH the card renders, not the transcript's frames. Sent on
218
227
  * every change, and the same object is available over GET /api/sessions/:id/plan, so a page that
219
228
  * loads after the agent process died still renders the real plan instead of nothing. */
@@ -241,6 +250,18 @@ export type ServerEvent =
241
250
  * this tab in the background (`document.hidden` — the page's own answer to 「我正在看就不弹」, made
242
251
  * where the fact lives), (3) draw it with `noticeId` as the notification `tag`. Everything about WHAT
243
252
  * happened and WHEN to say it is decided server-side. */
253
+ /** One background process as the agent reports it (issue #22). Field names are the wire's, not the
254
+ * registry's: `uptimeSeconds`/`exitCode` are normalised server-side so the page never has to know
255
+ * whether it is reading `list_sessions()` or a completion event. */
256
+ export interface BackgroundTask {
257
+ id: string;
258
+ command: string;
259
+ pid?: number | null;
260
+ status: "running" | "exited";
261
+ uptimeSeconds?: number;
262
+ exitCode?: number | null;
263
+ }
264
+
244
265
  export interface WebNotice {
245
266
  /** `upsert` = show (or replace) the card for `noticeId`; `dismiss` = take it away. */
246
267
  op: "upsert" | "dismiss";