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