agentus 0.7.0 → 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-20261020-2337`, 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-20261020-2337`,基座 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 {
@@ -60,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
60
60
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
61
61
  last segment sorts, so "newest" is a `sort -V`.
62
62
 
63
- **Current integration build: `agentus/v0.22.0/merge-20261020-2337`** — the newest tag on the fork,
63
+ **Current integration build: `agentus/v0.22.0/merge-20261021-1400`** — the newest tag on the fork,
64
64
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
65
65
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
66
66
  ever looks stale.
@@ -52,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
52
52
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
53
53
  可排序,所以「最新」就是一次 `sort -V`。
54
54
 
55
- **当前集成构建:`agentus/v0.22.0/merge-20261020-2337`** —— fork 上最新的那个 tag,基座 Hermes
55
+ **当前集成构建:`agentus/v0.22.0/merge-20261021-1400`** —— fork 上最新的那个 tag,基座 Hermes
56
56
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
57
57
  以它为准。
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.7.0",
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
@@ -507,6 +534,40 @@ const agent = () => ({
507
534
  }, runMs + 1200);
508
535
  }
509
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
+
510
571
  // `[bgquiet]` — a SILENT background process: running, no notify, no completion carrier. This is
511
572
  // what the agent's own polling looks like, and the cockpit hides it by default (issue #35).
512
573
  if (/\[bgquiet\]/.test(text)) {
@@ -706,6 +767,34 @@ const agent = () => ({
706
767
  }
707
768
  }
708
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
+
709
798
  // `[plan-long]` — the same frame with steps long enough to wrap on a phone. The operator's
710
799
  // complaint (2026-10-18) is about exactly this: a step that runs onto a second line on a 360px
711
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,6 +44,7 @@ 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";
@@ -58,6 +60,16 @@ import { PREFS_DEFAULT, agentSettings, type PlanHintSettings } from "../settings
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);
@@ -1979,7 +1991,10 @@ export class SessionManager {
1979
1991
  // 落库(操作者 2026-10-18 选的 A 档):刷新页面、重连、换设备之后,这行还在。
1980
1992
  // 折叠在 store 里做(见 Store.appendNote),因为「压缩开始 → 心跳 → 完成」和每回合的
1981
1993
  // 一次回忆都必须收敛成一行 —— 客户端折只治屏幕噪音,库里照样长心跳。
1982
- 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
+ });
1983
1998
  this.#emit({ t: "message", message: row });
1984
1999
  } else {
1985
2000
  // 剩下的(provider 等待的倒计时、租约闲聊)本来就是一秒就作废的呼吸声:只走 live 通道。
@@ -2723,6 +2738,11 @@ export function backgroundTasksOf(u: Record<string, unknown>): BackgroundTask[]
2723
2738
  // old agent that does not send it must land on `false` (silent) rather than a third state
2724
2739
  // nobody renders.
2725
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,
2726
2746
  });
2727
2747
  }
2728
2748
  return out;
@@ -2741,6 +2761,38 @@ export function visibleBackgroundTasks(tasks: BackgroundTask[]): BackgroundTask[
2741
2761
  return agentSettings().showSilentBackground ? tasks : tasks.filter((t) => t.notifyOnComplete === true);
2742
2762
  }
2743
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
+
2744
2796
  function statusKindOf(u: Record<string, unknown>): string | undefined {
2745
2797
  const meta = u?._meta as { hermes?: { statusKind?: unknown } } | undefined;
2746
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;
@@ -761,7 +761,26 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
761
761
  if (!sendOk) return send(res, 403, { error: COCKPIT_ERRORS.sendsDisabled });
762
762
  const target = rowOf(to);
763
763
  if (!target) return send(res, 404, { error: COCKPIT_ERRORS.outOfScope });
764
- if (target.cold) return send(res, 409, { error: COCKPIT_ERRORS.targetCold });
764
+ // #41: a cold target used to end the conversation here. Waking is now EXPLICIT (`--wake`),
765
+ // because it costs a process — but refusing every cold send made cross-session work
766
+ // unusable, since the idle reaper keeps targets cold by design.
767
+ const wantWake = body.wake === true;
768
+ let woke = false;
769
+ if (target.cold) {
770
+ if (!wantWake) {
771
+ return send(res, 409, {
772
+ error: COCKPIT_ERRORS.targetCold,
773
+ message: "the target has no running process; pass --wake (wake:true) to start it, then the message is delivered in the same call",
774
+ wakeable: !target.archivedAt,
775
+ });
776
+ }
777
+ try {
778
+ await mgr.resume(to);
779
+ woke = true;
780
+ } catch (e) {
781
+ return send(res, 409, { error: COCKPIT_ERRORS.wakeFailed, message: String((e as Error)?.message ?? e) });
782
+ }
783
+ }
765
784
  // Deliberately NOT `interrupt`: a cross-session message must never take the floor from a turn
766
785
  // the OPERATOR started. A busy target answers `target_busy` and the caller reports it as such.
767
786
  try {
@@ -775,7 +794,24 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
775
794
  const why = String((e as Error)?.message ?? e);
776
795
  return send(res, 409, { error: why.includes("turn already running") ? COCKPIT_ERRORS.targetBusy : why });
777
796
  }
778
- return send(res, 200, { delivered: true, to, code: "delivered" });
797
+ return send(res, 200, { delivered: true, to, code: "delivered", ...(woke ? { delivered_after_wake: true } : {}) });
798
+ }
799
+ if (url.pathname === "/api/cockpit/wake" && req.method === "POST") {
800
+ const body = await readJson(req);
801
+ const id = String(body.id ?? "");
802
+ if (!id) return send(res, 400, { error: COCKPIT_ERRORS.badRequest });
803
+ // Same gate as send (#41): waking is what makes a send possible, so the two answers must
804
+ // never disagree about whether an agent may pull another session's process up.
805
+ if (!sendOk) return send(res, 403, { error: COCKPIT_ERRORS.sendsDisabled });
806
+ const target = rowOf(id);
807
+ if (!target) return send(res, 404, { error: COCKPIT_ERRORS.outOfScope });
808
+ if (!target.cold) return send(res, 200, { schema_version: COCKPIT_SCHEMA_VERSION, woke: false, already_live: true, id });
809
+ try {
810
+ const info = await mgr.resume(id);
811
+ return send(res, 200, { schema_version: COCKPIT_SCHEMA_VERSION, woke: true, id, status: info.status });
812
+ } catch (e) {
813
+ return send(res, 409, { error: COCKPIT_ERRORS.wakeFailed, message: String((e as Error)?.message ?? e) });
814
+ }
779
815
  }
780
816
  return send(res, 404, { error: "unknown cockpit command" });
781
817
  }
@@ -3,7 +3,7 @@
3
3
  // messages carry monotonic per-session `seq` (reconnect replay anchor, AC6).
4
4
  import fs from "node:fs";
5
5
  import { DatabaseSync } from "node:sqlite";
6
- import type { BackendId, PlanSnapshot, SessionStatus, StoredMessage } from "../../../shared/src/index.js";
6
+ import type { BackendId, PlanSnapshot, SessionStatus, StatusNoteItem, StoredMessage } from "../../../shared/src/index.js";
7
7
  import type { BackendHandshake, BackendHealth, BackendKind, BackendRow } from "../acp/registry.js";
8
8
  import { normalizeItems } from "../plan/plan.js";
9
9
  import { DEFAULT_NATIVE_PLAN_SOURCE } from "../acp/backends.js";
@@ -965,7 +965,11 @@ export class Store {
965
965
  * (说明这中间没有别的消息落下),就更新它,而不是再加一行;位置变了(新消息进来了)就另起一行。
966
966
  * 于是「压缩中 → 完成」收敛成一行(保留开始时间),一个回合一次回忆也只留一行。
967
967
  */
968
- appendNote(sessionId: string, note: { category: string; text: string; createdAt?: number }): StoredMessage {
968
+ appendNote(sessionId: string, note: {
969
+ category: string; text: string; createdAt?: number;
970
+ /** memory 行的条目(issue #37)—— 只在 category==="memory" 时有意义。 */
971
+ items?: StatusNoteItem[]; truncated?: boolean;
972
+ }): StoredMessage {
969
973
  const createdAt = note.createdAt ?? Date.now();
970
974
  const edge = this.#db
971
975
  .prepare("select coalesce(max(seq),0) as seq from messages where session_id = ?")
@@ -974,9 +978,18 @@ export class Store {
974
978
  .prepare("select seq, payload, created_at from messages where session_id = ? and kind = 'note' order by seq desc limit 1")
975
979
  .get(sessionId) as { seq: number; payload: string; created_at: number } | undefined;
976
980
  if (last && last.seq === edge.seq) {
977
- const prev = JSON.parse(last.payload) as { category?: string; text?: string; hits?: number };
981
+ const prev = JSON.parse(last.payload) as {
982
+ category?: string; text?: string; hits?: number; items?: StatusNoteItem[]; truncated?: boolean;
983
+ };
978
984
  if (prev.category === note.category) {
979
- const payload = { category: note.category, text: note.text, hits: (prev.hits ?? 1) + 1 };
985
+ // 折叠只发生在「位置上没有别的消息插进来」时(压缩的开始/心跳/完成)。条目取**最新一次**:
986
+ // 折叠出来的是同一行,这行的内容是最后一次召回,而不是第一次的残留。
987
+ const payload: Record<string, unknown> = {
988
+ category: note.category, text: note.text, hits: (prev.hits ?? 1) + 1,
989
+ };
990
+ const items = note.items ?? prev.items;
991
+ if (items?.length) payload.items = items;
992
+ if (note.truncated ?? prev.truncated) payload.truncated = true;
980
993
  // `created_at` stays: it answers "这一轮什么时候开始回忆/压缩的", and the heartbeat is not news.
981
994
  this.#db
982
995
  .prepare("update messages set payload = ? where session_id = ? and seq = ?")
@@ -986,9 +999,10 @@ export class Store {
986
999
  };
987
1000
  }
988
1001
  }
989
- const row = this.appendMessage({
990
- sessionId, kind: "note", payload: { category: note.category, text: note.text, hits: 1 }, createdAt,
991
- });
1002
+ const payload: Record<string, unknown> = { category: note.category, text: note.text, hits: 1 };
1003
+ if (note.items?.length) payload.items = note.items;
1004
+ if (note.truncated) payload.truncated = true;
1005
+ const row = this.appendMessage({ sessionId, kind: "note", payload, createdAt });
992
1006
  // Bounded: a session left open for weeks must not keep every recall it ever did. 80 is well past
993
1007
  // what any transcript page shows, and a note is ~120 bytes.
994
1008
  const over = this.#db
@@ -263,6 +263,16 @@ export interface BackgroundTask {
263
263
  status: "running" | "exited";
264
264
  uptimeSeconds?: number;
265
265
  exitCode?: number | null;
266
+ /** What this row IS (issue #39). `process` = a `terminal(background=true)` child in the process
267
+ * registry; `delegation` = a live `delegate_task(background=true)` subagent, which never appears
268
+ * there. Absent on frames from an older agent, and absent must read as `process`: that is what
269
+ * every field above has always meant. */
270
+ kind?: "process" | "delegation";
271
+ /** Delegations only: the ledger's own phase (`running` / `stalling` / `finalizing`). `status`
272
+ * stays the host's two-state vocabulary, so a `finalizing` child still reads as running. */
273
+ phase?: string;
274
+ /** Delegations only: how many children one row stands for (a batch of N counts N). */
275
+ taskCount?: number;
266
276
  /** Does this process OWE the session a wake-up (`notify_on_complete`)? Issue #35: that is the
267
277
  * difference between 「agent 已经收工」 and 「agent 还在等这次唤醒才能继续」. Optional — an
268
278
  * older agent never sends it, and `undefined` must read as "silent", never as "unknown so
@@ -434,6 +444,18 @@ export type ClientCommand =
434
444
  signature?: string; // "<kind>:<toolCallTitle>" for always-allow memory
435
445
  };
436
446
 
447
+ /** 一条记忆召回条目 —— `note` 行 `payload.items` 的元素(issue #37)。
448
+ *
449
+ * 「recalled 8 memories」只说记忆被用上了,没说用上的是哪几条,于是「召回错了/召回旧了」和
450
+ * 「召回得好」在屏幕上长得一模一样。条目由 provider 侧截断(200 字符/条、6K/轮),server 再按
451
+ * 自己的上限(20 条 / 300 字符)限一次,因为这条数据来自 agent 进程并且要落库。
452
+ */
453
+ export interface StatusNoteItem {
454
+ text: string;
455
+ tags?: string[];
456
+ type?: string;
457
+ }
458
+
437
459
  export interface StoredMessage {
438
460
  seq: number; // monotonic per session — reconnect dedup anchor
439
461
  sessionId: string;