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 +6 -5
- package/README.zh-CN.md +2 -1
- package/docs/installation.md +1 -1
- package/docs/installation.zh-CN.md +1 -1
- package/package.json +6 -1
- package/packages/server/src/acp/session-manager.ts +106 -4
- package/packages/server/src/index.ts +40 -4
- package/packages/server/src/store/store.ts +28 -0
- package/packages/shared/src/index.ts +16 -4
- package/packages/web/dist/assets/index-Cr0Hw9eP.css +32 -0
- package/packages/web/dist/assets/index-DDGyKK0Y.js +127 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-1rw3zpcN.css +0 -32
- package/packages/web/dist/assets/index-BxGj4INU.js +0 -125
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, **
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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-
|
|
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-
|
|
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」。
|
package/docs/installation.md
CHANGED
|
@@ -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-
|
|
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-
|
|
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.
|
|
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(),
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|