agentus 0.4.5 → 0.6.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
@@ -50,12 +50,13 @@ 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, **background-job
54
- completion wake-ups** (a finished background process comes back as a turn of its own), and a
55
- **DeepSeek web backend** (search + extract on the model key) — come from the fork's integration
56
- build instead. That is a five-line install:
53
+ channel** (memory recall/save hints in the message stream), per-process skill roots, **project
54
+ skills made visible and switchable** (a repo's `.agents/skills` shows up as a `project_skills`
55
+ option instead of being skipped in silence), **background-job completion wake-ups** (a finished
56
+ background process comes back as a turn of its own), and a **DeepSeek web backend** (search +
57
+ extract on the model key) — come from the fork's integration build instead. That is a five-line install:
57
58
  [§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
58
- `agentus/v0.22.0/merge-20261018-1617`, on Hermes `v0.22.0`.
59
+ `agentus/v0.22.0/merge-20261019-2348`, on Hermes `v0.22.0`.
59
60
 
60
61
  The first slot you start will use the Hermes home **your own agent already uses**
61
62
  (`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
package/README.zh-CN.md CHANGED
@@ -45,11 +45,12 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
45
45
 
46
46
  裸装的 Hermes 就能跑会话;驾驶舱自己的那几项能力 —— 按会话切换的**思考深度**、跨重启存活的**权限模式**、
47
47
  **上下文预算**输入框、把记忆召回 / 存记忆提示送进消息流的 **status 通道**、按进程指定的技能根、
48
+ **项目 skill 可见可设**(仓库的 `.agents/skills` 会以 `project_skills` 选项出现,而不是被无声跳过)、
48
49
  **后台任务完成唤醒**(后台进程跑完会自己开一个回合回来),
49
50
  以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
50
51
  装法就五行:
51
52
  [§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
52
- `agentus/v0.22.0/merge-20261018-1617`,基座 Hermes `v0.22.0`。
53
+ `agentus/v0.22.0/merge-20261019-2348`,基座 Hermes `v0.22.0`。
53
54
 
54
55
  你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
55
56
  指定了另一个 —— 见下面的「HERMES_HOME」。
@@ -55,7 +55,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
55
55
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
56
56
  last segment sorts, so "newest" is a `sort -V`.
57
57
 
58
- **Current integration build: `agentus/v0.22.0/merge-20261018-1617`** — the newest tag on the fork,
58
+ **Current integration build: `agentus/v0.22.0/merge-20261019-2348`** — the newest tag on the fork,
59
59
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
60
60
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
61
61
  ever looks stale.
@@ -49,7 +49,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
49
49
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
50
50
  可排序,所以「最新」就是一次 `sort -V`。
51
51
 
52
- **当前集成构建:`agentus/v0.22.0/merge-20261018-1617`** —— fork 上最新的那个 tag,基座 Hermes
52
+ **当前集成构建:`agentus/v0.22.0/merge-20261019-2348`** —— fork 上最新的那个 tag,基座 Hermes
53
53
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
54
54
  以它为准。
55
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.4.5",
3
+ "version": "0.6.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": {
@@ -58,6 +58,7 @@
58
58
  "token-format": "tsx scripts/qa/token-format.mts",
59
59
  "terminal-palette": "tsx scripts/qa/terminal-palette.mts",
60
60
  "android-trust-merge": "node scripts/qa/android-trust-merge.mjs",
61
+ "cards-persist": "node scripts/qa/cards-persist.mjs",
61
62
  "plan-hint-sweep": "node scripts/qa/plan-hint-sweep.mjs",
62
63
  "plan-service-sweep": "node scripts/qa/plan-service-sweep.mjs",
63
64
  "plan-mcp-sweep": "node scripts/qa/plan-mcp-sweep.mjs",
@@ -65,6 +66,10 @@
65
66
  "notify-testbed": "tsx scripts/notify-testbed.mts",
66
67
  "notify-smoke": "tsx scripts/notify-smoke.mts",
67
68
  "web-notify-rules": "tsx scripts/qa/web-notify-rules.mts",
69
+ "notify-chime": "tsx scripts/qa/notify-chime.mts",
70
+ "ws-send-ack": "node scripts/qa/ws-send-ack.mjs",
71
+ "send-ack": "node scripts/qa/send-ack-page.mjs",
72
+ "composer-input": "node scripts/qa/composer-input-sweep.mjs",
68
73
  "web-notify-sweep": "node scripts/qa/web-notify-sweep.mjs",
69
74
  "apk": "bash scripts/build-apk.sh",
70
75
  "transcript-fold": "tsx scripts/qa/transcript-fold.mts",
@@ -177,6 +177,12 @@ interface LiveSession {
177
177
  timer: NodeJS.Timeout;
178
178
  }>;
179
179
  alwaysAllow: Set<string>; // "allow_always" remembered per live session only (AionUi F-PERM-05)
180
+ /** requestId → seq of the `approval` row this request already wrote into the transcript, so the
181
+ * decision can be filled into the SAME row instead of appending a second one. */
182
+ approvalSeq: Map<string, number>;
183
+ /** repo root → the `project-skills` row this session already shows, with its payload, so a
184
+ * second "untrusted" notice never stacks and a decision can rewrite the row in place. */
185
+ skillNoticeSeq: Map<string, { seq: number; payload: Record<string, unknown> }>;
180
186
  stderrBuf: string[];
181
187
  /** What the agent says it can do (initialize.agentCapabilities). `fork` gates title
182
188
  * regeneration, `load` gates resume; `image` is the one a TURN can be refused on — see
@@ -382,12 +388,25 @@ export class SessionManager {
382
388
  *
383
389
  * The decision is `cancelled` — what the agent would have received had nobody ever answered.
384
390
  */
391
+ /** 把一条已经落库的卡片换成新 payload,并推给所有客户端。
392
+ *
393
+ * 审批卡与项目 skill 卡都是「先落行、后补决定」:行必须在请求到达那一刻就写进转录(否则
394
+ * 刷新之后「弹过这张卡」这件事就不存在了),决定要等操作者点。改 payload 而不改 seq ——
395
+ * seq 决定它在聊天记录里的位置,改了它会跳到别的地方。 */
396
+ #settleRow(live: LiveSession, seq: number | undefined, payload: Record<string, unknown>): void {
397
+ if (seq === undefined) return;
398
+ const row = this.#store.updateMessagePayload(seq, live.info.id, payload);
399
+ if (row) this.#emit({ t: "message", message: row });
400
+ }
401
+
385
402
  #dropPending(live: LiveSession, decision: PermissionDecision = { outcome: "cancelled" }): void {
386
403
  if (!live.pendingPermissions.size) return;
387
- for (const [requestId, { resolve, timer }] of live.pendingPermissions) {
404
+ for (const [requestId, { resolve, timer, view }] of live.pendingPermissions) {
388
405
  clearTimeout(timer);
389
406
  resolve({ outcome: decision });
390
407
  this.#emit({ t: "permission-resolved", requestId, decision });
408
+ this.#settleRow(live, live.approvalSeq.get(requestId), { ...view, decision });
409
+ live.approvalSeq.delete(requestId);
391
410
  }
392
411
  live.pendingPermissions.clear();
393
412
  }
@@ -501,7 +520,8 @@ export class SessionManager {
501
520
  ...(opts.system ? { system: true } : {}),
502
521
  createdAt: now, modes: null, configOptions: [], commands: [],
503
522
  },
504
- child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
523
+ child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(),
524
+ approvalSeq: new Map(), skillNoticeSeq: new Map(), stderrBuf: [],
505
525
  background: [], pendingCompletions: [],
506
526
  caps: { fork: false, load: false, image: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
507
527
  planToken: randomUUID(),
@@ -686,7 +706,8 @@ export class SessionManager {
686
706
  usage: (row.usage ?? null) as SessionInfo["usage"],
687
707
  commands: normCommands(row.commands),
688
708
  },
689
- child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
709
+ child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(),
710
+ approvalSeq: new Map(), skillNoticeSeq: new Map(), stderrBuf: [],
690
711
  background: [], pendingCompletions: [],
691
712
  caps: { fork: false, load: false, image: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
692
713
  planToken: randomUUID(),
@@ -972,6 +993,12 @@ export class SessionManager {
972
993
  * message, no attribution, indistinguishable from something they typed).
973
994
  */
974
995
  origin?: { sessionId: string; title?: string | null };
996
+ /**
997
+ * Client-generated id of the prompt that is being sent (issue #26). Echoed on the `turn-start`
998
+ * this call causes, so a page can prove its frame landed instead of assuming `ws.send()` did
999
+ * something. Opaque to the server: it comes back unchanged, it is never stored.
1000
+ */
1001
+ pid?: string;
975
1002
  } = {},
976
1003
  ): Promise<void> {
977
1004
  const s = this.#need(sessionId);
@@ -1038,7 +1065,7 @@ export class SessionManager {
1038
1065
  // Timed from the announcement, not from the prompt call: the operator's wait INCLUDES the
1039
1066
  // moment the card lit up. Only the queue-wait above is excluded (that is other turns' time).
1040
1067
  const turnStartedAt = Date.now();
1041
- this.#emit({ t: "turn-start", sessionId, trace: this.#turnTrace(s) });
1068
+ this.#emit({ t: "turn-start", sessionId, trace: this.#turnTrace(s), ...(opts.pid ? { pid: opts.pid } : {}) });
1042
1069
  const msg = this.#store.appendMessage({
1043
1070
  sessionId,
1044
1071
  kind: "user",
@@ -1233,6 +1260,15 @@ export class SessionManager {
1233
1260
  rejected = true;
1234
1261
  actual = String(s.info.configOptions.find((o) => o.id === configId)?.currentValue ?? "");
1235
1262
  }
1263
+ if (configId === "project_skills" && !rejected) {
1264
+ // 决定落在卡上:卡片是「问过」的证据,改配置却不改卡,读起来就像问完没下文。
1265
+ for (const [root, cur] of s.skillNoticeSeq) {
1266
+ if (cur.payload.root && String(cur.payload.root) !== root) continue;
1267
+ const payload = { ...cur.payload, state: actual };
1268
+ cur.payload = payload;
1269
+ this.#settleRow(s, cur.seq, payload);
1270
+ }
1271
+ }
1236
1272
  if (rejected) {
1237
1273
  this.#emit({
1238
1274
  t: "config-rejected",
@@ -1826,6 +1862,8 @@ export class SessionManager {
1826
1862
  s.pendingPermissions.delete(requestId);
1827
1863
  pending.resolve({ outcome: decision });
1828
1864
  this.#emit({ t: "permission-resolved", requestId, decision });
1865
+ this.#settleRow(s, s.approvalSeq.get(requestId), { ...pending.view, decision });
1866
+ s.approvalSeq.delete(requestId);
1829
1867
  return true;
1830
1868
  }
1831
1869
 
@@ -1886,6 +1924,33 @@ export class SessionManager {
1886
1924
  this.#queueOrWakeCompletion(live, finishedText);
1887
1925
  return;
1888
1926
  }
1927
+ // Project skills the agent is NOT loading (fork: `_meta.hermes = {kind:"project_skills"}`).
1928
+ // The same door, one more kind. It rides the live status channel on purpose: the sentence is
1929
+ // about THIS open, and persisting it would put 「这个仓库有 N 个 skill 未启用」 above a
1930
+ // conversation from last month — the very thing the status path already refuses to store.
1931
+ const projectSkills = projectSkillsNoticeOf(u);
1932
+ if (projectSkills) {
1933
+ // 一张卡而不是一行闪现的状态:操作者要能在聊天记录里看到「这个仓库有 N 个 skill 没启用」,
1934
+ // 并就地做决定(信任 / 先不)。每个 repo root 只落一次,重复打开不会堆卡片。
1935
+ const rootKey = projectSkills.root || "(no root)";
1936
+ if (!live.skillNoticeSeq.has(rootKey)) {
1937
+ const payload: Record<string, unknown> = {
1938
+ root: projectSkills.root,
1939
+ count: projectSkills.count,
1940
+ text: projectSkills.text,
1941
+ state: "ask",
1942
+ };
1943
+ const row = this.#store.appendMessage({
1944
+ sessionId: live.info.id,
1945
+ kind: "project-skills",
1946
+ payload,
1947
+ createdAt: Date.now(),
1948
+ });
1949
+ live.skillNoticeSeq.set(rootKey, { seq: row.seq, payload });
1950
+ this.#emit({ t: "message", message: row });
1951
+ }
1952
+ return;
1953
+ }
1889
1954
  const statusText = statusLineOf(u);
1890
1955
  if (statusText) {
1891
1956
  const statusKind = statusKindOf(u);
@@ -2068,6 +2133,18 @@ export class SessionManager {
2068
2133
  outcome: { outcome: "selected", optionId: remembered.optionId } as never,
2069
2134
  });
2070
2135
  }
2136
+ // 落一行再弹卡(操作者 2026-10-19):审批只以 transient 事件存在时,弹窗关掉/刷新/换设备之后
2137
+ // 就什么都不剩了 —— 「刚才那个请求我批的什么」无从查起。行在请求到达这一刻写,决定稍后补进
2138
+ // 同一行(#settleRow),于是聊天记录里留下一条带结论的记录。
2139
+ const approvalRow = this.#store.appendMessage({
2140
+ sessionId: live.info.id,
2141
+ kind: "approval",
2142
+ payload: { ...view, decision: null },
2143
+ createdAt: view.createdAt,
2144
+ });
2145
+ live.approvalSeq.set(requestId, approvalRow.seq);
2146
+ this.#touch(live, approvalRow.createdAt);
2147
+ this.#emit({ t: "message", message: approvalRow });
2071
2148
  this.#emit({ t: "permission", request: view });
2072
2149
  return new Promise<RequestPermissionResponse>((resolve) => {
2073
2150
  const timer = setTimeout(() => {
@@ -2075,6 +2152,9 @@ export class SessionManager {
2075
2152
  const d: PermissionDecision = { outcome: "cancelled" };
2076
2153
  resolve({ outcome: d });
2077
2154
  this.#emit({ t: "permission-resolved", requestId, decision: d });
2155
+ // 超时也是「有结论」:卡片上要留下「没人答,已取消」,不能一直挂着等批准。
2156
+ this.#settleRow(live, live.approvalSeq.get(requestId), { ...view, decision: d });
2157
+ live.approvalSeq.delete(requestId);
2078
2158
  }, PERMISSION_TIMEOUT_MS);
2079
2159
  live.pendingPermissions.set(requestId, { view, resolve, timer });
2080
2160
  });
@@ -2563,6 +2643,28 @@ function hermesPayload(u: Record<string, unknown>): Record<string, unknown> {
2563
2643
  return meta?.hermes ?? {};
2564
2644
  }
2565
2645
 
2646
+ /** Untrusted project skills the agent reported at session open, or null.
2647
+ *
2648
+ * Shape (fork `acp_adapter/server.py::_notify_project_skills`): empty thought chunk with
2649
+ * `_meta.hermes = {kind:"project_skills", state:"untrusted", root, skillCount, text}`. The agent
2650
+ * sends it only when the repo actually carries skills AND nobody has decided yet — so a plain
2651
+ * checkout never produces one, and an unknown kind is still ignored silently (that is what lets
2652
+ * the two repositories ship independently).
2653
+ */
2654
+ function projectSkillsNoticeOf(
2655
+ u: Record<string, unknown>,
2656
+ ): { text: string; root: string; count: number } | null {
2657
+ if (hermesKindOf(u) !== "project_skills") return null;
2658
+ const payload = hermesPayload(u);
2659
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
2660
+ if (!text) return null;
2661
+ return {
2662
+ text,
2663
+ root: typeof payload.root === "string" ? payload.root : "",
2664
+ count: typeof payload.skillCount === "number" ? payload.skillCount : 0,
2665
+ };
2666
+ }
2667
+
2566
2668
  /** The completion sentence a finished background process produced (#22), or null. */
2567
2669
  function completionTextOf(u: Record<string, unknown>): string | null {
2568
2670
  if (hermesKindOf(u) !== "completion") return null;
@@ -450,8 +450,9 @@ function runPrompt(
450
450
  text: string,
451
451
  attachments: PromptAttachment[] = [],
452
452
  interrupt = false,
453
+ pid?: string,
453
454
  ): void {
454
- void mgr.prompt(sessionId, text, attachments, { interrupt }).catch((e: unknown) => {
455
+ void mgr.prompt(sessionId, text, attachments, { interrupt, pid }).catch((e: unknown) => {
455
456
  const msg = describeAcpError(e);
456
457
  const tail = mgr.stderrTail(sessionId);
457
458
  console.error(`[agentus] prompt failed for ${sessionId}: ${msg}${tail ? `\n agent stderr tail:\n ${tail}` : ""}`);
@@ -463,7 +464,10 @@ function runPrompt(
463
464
  const lost = msg === "turn already running" || msg === IMAGE_UNSUPPORTED;
464
465
  emit({ t: "message", message: {
465
466
  seq: -1, sessionId, kind: "meta",
466
- payload: { text: `turn failed: ${msg}`, ...(lost ? { rejectedPrompt: text } : {}) },
467
+ // `pid` rides along (issue #26) so the page can pair this refusal with the prompt it is still
468
+ // waiting to acknowledge: without it the refusal and the ack timeout both put the same sentence
469
+ // back in line, and the operator sees his message twice.
470
+ payload: { text: `turn failed: ${msg}`, ...(lost ? { rejectedPrompt: text } : {}), ...(pid ? { pid } : {}) },
467
471
  createdAt: Date.now(),
468
472
  } });
469
473
  // `turn-end` is the frame that says A TURN IS OVER. For the two "nothing left the client" refusals
@@ -1510,7 +1514,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1510
1514
  const attachments = Array.isArray(body.attachments) ? (body.attachments as PromptAttachment[]) : [];
1511
1515
  if (!text && !attachments.length) return send(res, 400, { error: "empty prompt" });
1512
1516
  // fire & forget: stream arrives via WS; client watches turn-start/end
1513
- void runPrompt(id, text, attachments, body.interrupt === true);
1517
+ void runPrompt(id, text, attachments, body.interrupt === true, typeof body.pid === "string" ? body.pid : undefined);
1514
1518
  return send(res, 202, { ok: true });
1515
1519
  }
1516
1520
  if (req.method === "POST" && sub === "/workspace") {
@@ -1795,6 +1799,9 @@ const handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
1795
1799
  httpServer.on("upgrade", handleUpgrade);
1796
1800
  wss.on("connection", (ws) => {
1797
1801
  const clientId = randomUUID().slice(0, 8);
1802
+ // Any frame — including a pong — proves the peer is alive (see the heartbeat below).
1803
+ (ws as WebSocket & { isAlive?: boolean }).isAlive = true;
1804
+ ws.on("pong", () => { (ws as WebSocket & { isAlive?: boolean }).isAlive = true; });
1798
1805
  clients.set(clientId, { ws, lastSeen: new Map() });
1799
1806
  const sendEvt = (e: ServerEvent) => {
1800
1807
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(e));
@@ -1831,7 +1838,7 @@ wss.on("connection", (ws) => {
1831
1838
  break;
1832
1839
  }
1833
1840
  case "prompt":
1834
- void runPrompt(cmd.sessionId, cmd.text, cmd.attachments ?? [], cmd.interrupt === true);
1841
+ void runPrompt(cmd.sessionId, cmd.text, cmd.attachments ?? [], cmd.interrupt === true, cmd.pid);
1835
1842
  break;
1836
1843
  case "cancel":
1837
1844
  await mgr.cancel(cmd.sessionId);
@@ -1865,6 +1872,35 @@ wss.on("connection", (ws) => {
1865
1872
  ws.on("close", () => clients.delete(clientId));
1866
1873
  });
1867
1874
 
1875
+ // ---- liveness: the half-open socket ---------------------------------------------------------
1876
+ // A TCP connection that dies without a FIN (Wi-Fi switch, laptop sleep, NAT timeout) stays OPEN in
1877
+ // BOTH ends: the page's `send()` succeeds into the void, and this server keeps a client that will
1878
+ // never read again. There is no traffic to fail on, so nothing above this line can notice — and the
1879
+ // operator's message is written into a socket nobody owns, with no bubble and no error (issue #26,
1880
+ // measured live 2026-10-11 with the network cut). Ping/pong is the only detector the protocol gives
1881
+ // us: browsers answer `ping` automatically, so a missing pong means the peer is gone.
1882
+ //
1883
+ // 20s, not 60s: the window between "socket died" and "page knows" is exactly how long a prompt can
1884
+ // vanish, and the ack timeout on the client (8s) covers what happens inside one beat. An idle
1885
+ // cockpit costs 3 tiny frames a minute.
1886
+ const HEARTBEAT_MS = Number(process.env.AGENTUS_WS_HEARTBEAT_MS ?? "") || 20_000;
1887
+ const heartbeat = setInterval(() => {
1888
+ for (const [id, client] of clients) {
1889
+ const ws = client.ws as WebSocket & { isAlive?: boolean };
1890
+ if (ws.isAlive === false) {
1891
+ // No pong since the last beat: the peer is not there. `terminate()` (not `close()`) because a
1892
+ // half-open peer never answers the closing handshake either — this is the only way out.
1893
+ ws.terminate();
1894
+ clients.delete(id);
1895
+ continue;
1896
+ }
1897
+ ws.isAlive = false;
1898
+ try { ws.ping(); } catch { /* already gone; the next beat reaps it */ }
1899
+ }
1900
+ }, HEARTBEAT_MS);
1901
+ heartbeat.unref();
1902
+
1903
+
1868
1904
  // ---- the workspace panel's terminal -----------------------------------------
1869
1905
  // A shell is a stream, so it gets its own socket instead of riding the event bus:
1870
1906
  // one socket = one shell, and closing the socket (or the tab) kills the shell.
@@ -924,6 +924,34 @@ export class Store {
924
924
  return { ...m, seq };
925
925
  }
926
926
 
927
+ /**
928
+ * 换掉一行已有消息的 payload(seq 与 created_at 不动)。
929
+ *
930
+ * 为什么需要:审批卡、项目 skill 卡都**先生成一模一样的行、随后才由操作者做决定**。行必须在
931
+ * 请求到达那一刻就落库(否则「弹出来过」这件事在刷新后就不存在),而决定要在几秒/几分钟后
932
+ * 补上。改 seq 会让它在转录里换位置,改 created_at 会让「什么时候问的」变成「什么时候答的」。
933
+ */
934
+ updateMessagePayload(seq: number, sessionId: string, payload: unknown): StoredMessage | null {
935
+ this.#db
936
+ .prepare("update messages set payload = ? where session_id = ? and seq = ?")
937
+ .run(JSON.stringify(payload), sessionId, seq);
938
+ const row = this.#db
939
+ .prepare("select seq, session_id, kind, payload, tool_call_id, created_at from messages where session_id = ? and seq = ?")
940
+ .get(sessionId, seq) as
941
+ | { seq: number; session_id: string; kind: string; payload: string; tool_call_id: string | null; created_at: number }
942
+ | undefined;
943
+ if (!row) return null;
944
+ return {
945
+ seq: row.seq,
946
+ sessionId: row.session_id,
947
+ kind: row.kind as StoredMessage["kind"],
948
+ payload: JSON.parse(row.payload) as unknown,
949
+ toolCallId: row.tool_call_id ?? undefined,
950
+ createdAt: row.created_at,
951
+ };
952
+ }
953
+
954
+
927
955
  /**
928
956
  * 一条「状态行」落库(操作者 2026-10-18):回忆了几条记忆、上下文压缩了、有警告。
929
957
  *
@@ -206,7 +206,10 @@ export type ServerEvent =
206
206
  durationMs?: number }
207
207
  /** per-turn trace: what model/effort/mode this turn is actually running with
208
208
  * (AionUi F-DISPLAY-11 lineage — "why did it behave differently?" ) */
209
- | { t: "turn-start"; sessionId: string; trace?: TurnTrace }
209
+ /** `pid` is the id of the prompt that started this turn, copied from the client's command (issue
210
+ * #26): it is the only thing that can prove a particular frame landed. Absent for turns the page
211
+ * did not start (a call utterance, the cockpit's cross-session `send`, a resumed transcript). */
212
+ | { t: "turn-start"; sessionId: string; trace?: TurnTrace; pid?: string }
210
213
  /** A one-line status from the AGENT itself (issue #18): `👁️ Hindsight — recalled 8 memories`,
211
214
  * a compaction countdown, a provider wait. Deliberately NOT a transcript row and not persisted —
212
215
  * it describes what the process is doing NOW, so the page shows the latest one beside the running
@@ -407,7 +410,12 @@ export type ClientCommand =
407
410
  * wait for it to actually stop before sending this prompt. Call mode sends every utterance
408
411
  * this way (the operator speaking over the agent IS the interrupt), which is also what
409
412
  * removes the race between an async cancel and the next prompt. */
410
- | { t: "prompt"; sessionId: string; text: string; attachments?: PromptAttachment[]; interrupt?: boolean }
413
+ | { t: "prompt"; sessionId: string; text: string; attachments?: PromptAttachment[]; interrupt?: boolean;
414
+ /** Client-generated id for THIS prompt, echoed back on the `turn-start` it causes (issue #26).
415
+ * Without it a page can only guess whether its frame landed: a socket that dies without a FIN
416
+ * stays `OPEN` in JS, so `send()` returns true for a frame nobody received and the operator's
417
+ * words vanish with no error. With it, "the server started a turn for prompt X" is a fact. */
418
+ pid?: string }
411
419
  | { t: "cancel"; sessionId: string }
412
420
  | { t: "set-mode"; sessionId: string; modeId: string }
413
421
  | { t: "set-config"; sessionId: string; configId: string; value: string | number | boolean }
@@ -427,8 +435,12 @@ export interface StoredMessage {
427
435
  /** `note` = a 「状态行」 that is not a message: 「recalled 8 memories」 / 「compacting」 / a warning.
428
436
  * Deliberately a transcript row (not a separate table): its position IS the seq — after the
429
437
  * operator's sentence, before the reply — and the page already orders, pages and replays by seq.
430
- * `meta` stays what it was: a line the HOST says (a rejected send), never persisted. */
431
- kind: "user" | "agent" | "thought" | "tool" | "plan" | "meta" | "note";
438
+ * `meta` stays what it was: a line the HOST says (a rejected send), never persisted.
439
+ * `approval` = an approval request as a transcript row (it asks a question, then carries the
440
+ * decision), and `project-skills` = the agent reporting that this repo ships skills it is NOT
441
+ * loading. Both are DUrable on purpose: a modal that vanishes leaves no record of what was
442
+ * asked or what was answered. */
443
+ kind: "user" | "agent" | "thought" | "tool" | "plan" | "meta" | "note" | "approval" | "project-skills";
432
444
  payload: unknown; // raw ACP content/update fragment (kept lossless)
433
445
  toolCallId?: string; // set for kind=tool; upsert key
434
446
  createdAt: number;