agentus 0.5.0 → 0.6.1
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 +5 -2
- package/docs/installation.zh-CN.md +4 -2
- package/package.json +3 -1
- package/packages/server/src/acp/cold-page.ts +82 -0
- package/packages/server/src/acp/session-manager.ts +132 -12
- package/packages/server/src/cockpit/api.ts +14 -2
- package/packages/server/src/index.ts +4 -1
- package/packages/server/src/store/store.ts +28 -0
- package/packages/shared/src/index.ts +6 -2
- package/packages/web/dist/assets/index-Cr0Hw9eP.css +32 -0
- package/packages/web/dist/assets/{index-SSGsR6n4.js → index-DDGyKK0Y.js} +52 -52
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-xZyah18L.css +0 -32
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
|
@@ -39,7 +39,10 @@ mode that survives a restart** and `session/load`, a free-form **context budget*
|
|
|
39
39
|
cockpit's message stream, **per-process skill roots** (`HERMES_EXTERNAL_SKILLS_DIRS`, which is how a
|
|
40
40
|
slot sees the cockpit's own skill without anything being written into your `~/.hermes`),
|
|
41
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),
|
|
42
|
+
a turn of its own, so the agent resumes instead of the turn simply ending), **project skills the agent
|
|
43
|
+
can see, trust and edit** (the cockpit reports which project skills a repo has and which tier you
|
|
44
|
+
trust, and writes the trust level back to config — it rides the same status channel; a released
|
|
45
|
+
Hermes silently skips untrusted project skills without saying so), and a
|
|
43
46
|
**DeepSeek web backend** (`search` + `extract` on top of `DEEPSEEK_API_KEY` — DeepSeek has no separate
|
|
44
47
|
search key).
|
|
45
48
|
They live on the ACP branches of the fork
|
|
@@ -55,7 +58,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
|
|
|
55
58
|
ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
|
|
56
59
|
last segment sorts, so "newest" is a `sort -V`.
|
|
57
60
|
|
|
58
|
-
**Current integration build: `agentus/v0.22.0/merge-
|
|
61
|
+
**Current integration build: `agentus/v0.22.0/merge-20261019-2348`** — the newest tag on the fork,
|
|
59
62
|
built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
|
|
60
63
|
`TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
|
|
61
64
|
ever looks stale.
|
|
@@ -35,7 +35,9 @@ brew install node # 或者 https://nodejs.org/en/download
|
|
|
35
35
|
(带 256K/512K/768K/1M 预设)、把记忆召回 / 存记忆提示送进驾驶舱消息流的 **status 通道**,以及
|
|
36
36
|
**按进程指定的技能根**(`HERMES_EXTERNAL_SKILLS_DIRS` —— 驾驶舱靠它让槽位看见自己的 skill,
|
|
37
37
|
不往你的 `~/.hermes` 写任何东西)、**后台任务完成唤醒**(`terminal(background=true)` 的进程跑完会被
|
|
38
|
-
中继成一个新回合,agent
|
|
38
|
+
中继成一个新回合,agent 自己接着干,而不是回合就这么结束)、**项目 skill 的可见 / 可信任 / 可写**
|
|
39
|
+
(驾驶舱会告诉 agent 这个仓库有哪些项目 skill、你信任哪个层级,并能把信任层级写回配置 —— 信号走
|
|
40
|
+
同一条 status 通道;裸装 Hermes 遇到没信任的项目 skill 是悄悄跳过、不吭声的),以及 **DeepSeek 的 web 后端**
|
|
39
41
|
(`search` + `extract` 都复用
|
|
40
42
|
`DEEPSEEK_API_KEY` —— DeepSeek 没有单独的搜索密钥)。它们在 fork
|
|
41
43
|
[`LiangYang666/hermes-agent`](https://github.com/LiangYang666/hermes-agent) 的 ACP 分支上,
|
|
@@ -49,7 +51,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
|
|
|
49
51
|
tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
|
|
50
52
|
可排序,所以「最新」就是一次 `sort -V`。
|
|
51
53
|
|
|
52
|
-
**当前集成构建:`agentus/v0.22.0/merge-
|
|
54
|
+
**当前集成构建:`agentus/v0.22.0/merge-20261019-2348`** —— fork 上最新的那个 tag,基座 Hermes
|
|
53
55
|
`v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
|
|
54
56
|
以它为准。
|
|
55
57
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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,8 @@
|
|
|
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",
|
|
62
|
+
"cold-paging": "tsx scripts/qa/cold-paging.mts",
|
|
61
63
|
"plan-hint-sweep": "node scripts/qa/plan-hint-sweep.mjs",
|
|
62
64
|
"plan-service-sweep": "node scripts/qa/plan-service-sweep.mjs",
|
|
63
65
|
"plan-mcp-sweep": "node scripts/qa/plan-mcp-sweep.mjs",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Paging for the COLD session list — the rows that exist on disk with no process behind them.
|
|
2
|
+
*
|
|
3
|
+
* Issue #34: the page used to be ONE global top-N by `lastAt`. Every consumer (rail grouping, the
|
|
4
|
+
* rail's 展开其余 counter, search, `cockpit session list --workspace`) then treated that page as the
|
|
5
|
+
* complete set, so the busiest workspace ate the whole page and every other workspace's cold
|
|
6
|
+
* sessions vanished. It is not a cosmetic loss: a cold row is the ONLY handle that can wake that
|
|
7
|
+
* session, so "not in the page" means "unreachable from the UI, even though the transcript is fine".
|
|
8
|
+
*
|
|
9
|
+
* The intent behind the old cap is still real — crash debris accumulates without anyone choosing it
|
|
10
|
+
* — so it is kept, but moved to the right axis: **N per workspace** (the same axis the rail already
|
|
11
|
+
* groups by) plus a much larger total ceiling as the anti-bloat backstop.
|
|
12
|
+
*
|
|
13
|
+
* Pure on purpose: the caller decides which rows EXIST (the cockpit's own scope rule), this decides
|
|
14
|
+
* which of them survive the page. That way the rule is tested without a server or a browser. */
|
|
15
|
+
|
|
16
|
+
import type { SessionInfo } from "@agentus/shared";
|
|
17
|
+
|
|
18
|
+
/** How many cold rows a single workspace keeps by default. The rail shows `RAIL_RECENT` (5) before
|
|
19
|
+
* 「展开其余」, so 20 is the expand depth, not the visible depth. */
|
|
20
|
+
export const COLD_PER_WORKSPACE = 20;
|
|
21
|
+
|
|
22
|
+
/** Hard ceiling across all workspaces. Chosen to be far above any real installation's workspace
|
|
23
|
+
* count × 20 while still bounding what a crash-storm can push into one WS frame. */
|
|
24
|
+
export const COLD_TOTAL_MAX = 400;
|
|
25
|
+
|
|
26
|
+
/** The cockpit CLI asks for a deeper page than the rail: an agent asking "what is going on in this
|
|
27
|
+
* workspace" is not browsing, and it filters by `--workspace` on top. */
|
|
28
|
+
export const COLD_PER_WORKSPACE_COCKPIT = 50;
|
|
29
|
+
|
|
30
|
+
export interface ColdPage<T> {
|
|
31
|
+
/** Newest-first, at most `perWorkspace` per workspace and `totalMax` overall. */
|
|
32
|
+
rows: T[];
|
|
33
|
+
/** How many cold rows existed before paging — so a caller can tell an empty cockpit from a
|
|
34
|
+
* truncated one. */
|
|
35
|
+
total: number;
|
|
36
|
+
/** `total - rows.length`: the rows this page left out. Non-zero means "this is a partial view". */
|
|
37
|
+
omitted: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Which bucket a cold row belongs to: the workspace the operator sees in the rail, falling back to
|
|
41
|
+
* the cwd exactly like `sessionRow()` does — the two must not disagree, or a row would be paged by
|
|
42
|
+
* one key and displayed under another. */
|
|
43
|
+
export function coldKeyOf(row: Pick<SessionInfo, "workspace" | "cwd">): string {
|
|
44
|
+
return row.workspace || row.cwd || "/";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Sort rule shared with `list()`: recent activity first, ties broken by creation. */
|
|
48
|
+
export function byActivity(a: Pick<SessionInfo, "lastAt" | "createdAt">, b: Pick<SessionInfo, "lastAt" | "createdAt">): number {
|
|
49
|
+
return (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bucket-aware cold paging.
|
|
54
|
+
*
|
|
55
|
+
* `sorted` must already be in the caller's display order (newest first). Each workspace keeps the
|
|
56
|
+
* first `perWorkspace` rows of that order; the survivors are merged and re-sorted so the overall list
|
|
57
|
+
* still reads newest-first, then cut at `totalMax` only if the merge exceeds it.
|
|
58
|
+
*/
|
|
59
|
+
export function coldPage<T extends Pick<SessionInfo, "workspace" | "cwd" | "lastAt" | "createdAt">>(
|
|
60
|
+
sorted: readonly T[],
|
|
61
|
+
perWorkspace: number = COLD_PER_WORKSPACE,
|
|
62
|
+
totalMax: number = COLD_TOTAL_MAX,
|
|
63
|
+
): ColdPage<T> {
|
|
64
|
+
const total = sorted.length;
|
|
65
|
+
const per = Math.max(1, Math.floor(perWorkspace));
|
|
66
|
+
const cap = Math.max(per, Math.floor(totalMax));
|
|
67
|
+
|
|
68
|
+
const buckets = new Map<string, T[]>();
|
|
69
|
+
for (const row of sorted) {
|
|
70
|
+
const key = coldKeyOf(row);
|
|
71
|
+
const bucket = buckets.get(key);
|
|
72
|
+
if (!bucket) buckets.set(key, [row]);
|
|
73
|
+
else if (bucket.length < per) bucket.push(row);
|
|
74
|
+
// else: this workspace already has its page — the rest of its rows are the ones we omit
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const kept: T[] = [];
|
|
78
|
+
for (const bucket of buckets.values()) kept.push(...bucket);
|
|
79
|
+
kept.sort(byActivity);
|
|
80
|
+
const rows = kept.length > cap ? kept.slice(0, cap) : kept;
|
|
81
|
+
return { rows, total, omitted: total - rows.length };
|
|
82
|
+
}
|
|
@@ -14,6 +14,14 @@ import type {
|
|
|
14
14
|
RequestPermissionResponse,
|
|
15
15
|
} from "@agentclientprotocol/sdk";
|
|
16
16
|
import { BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
|
|
17
|
+
import {
|
|
18
|
+
byActivity,
|
|
19
|
+
coldPage as pageCold,
|
|
20
|
+
COLD_PER_WORKSPACE,
|
|
21
|
+
COLD_PER_WORKSPACE_COCKPIT,
|
|
22
|
+
COLD_TOTAL_MAX,
|
|
23
|
+
type ColdPage,
|
|
24
|
+
} from "./cold-page.js";
|
|
17
25
|
import { SKILLS_ROOT, installSkills, resolveSkillsDir } from "../skills/install.js";
|
|
18
26
|
import { classifyError, checkedHealth, effectiveArgs, handshakeFrom, rowToSpec } from "./registry.js";
|
|
19
27
|
import type { BackendHandshake, CheckErrorCode } from "./registry.js";
|
|
@@ -177,6 +185,12 @@ interface LiveSession {
|
|
|
177
185
|
timer: NodeJS.Timeout;
|
|
178
186
|
}>;
|
|
179
187
|
alwaysAllow: Set<string>; // "allow_always" remembered per live session only (AionUi F-PERM-05)
|
|
188
|
+
/** requestId → seq of the `approval` row this request already wrote into the transcript, so the
|
|
189
|
+
* decision can be filled into the SAME row instead of appending a second one. */
|
|
190
|
+
approvalSeq: Map<string, number>;
|
|
191
|
+
/** repo root → the `project-skills` row this session already shows, with its payload, so a
|
|
192
|
+
* second "untrusted" notice never stacks and a decision can rewrite the row in place. */
|
|
193
|
+
skillNoticeSeq: Map<string, { seq: number; payload: Record<string, unknown> }>;
|
|
180
194
|
stderrBuf: string[];
|
|
181
195
|
/** What the agent says it can do (initialize.agentCapabilities). `fork` gates title
|
|
182
196
|
* regeneration, `load` gates resume; `image` is the one a TURN can be refused on — see
|
|
@@ -382,12 +396,25 @@ export class SessionManager {
|
|
|
382
396
|
*
|
|
383
397
|
* The decision is `cancelled` — what the agent would have received had nobody ever answered.
|
|
384
398
|
*/
|
|
399
|
+
/** 把一条已经落库的卡片换成新 payload,并推给所有客户端。
|
|
400
|
+
*
|
|
401
|
+
* 审批卡与项目 skill 卡都是「先落行、后补决定」:行必须在请求到达那一刻就写进转录(否则
|
|
402
|
+
* 刷新之后「弹过这张卡」这件事就不存在了),决定要等操作者点。改 payload 而不改 seq ——
|
|
403
|
+
* seq 决定它在聊天记录里的位置,改了它会跳到别的地方。 */
|
|
404
|
+
#settleRow(live: LiveSession, seq: number | undefined, payload: Record<string, unknown>): void {
|
|
405
|
+
if (seq === undefined) return;
|
|
406
|
+
const row = this.#store.updateMessagePayload(seq, live.info.id, payload);
|
|
407
|
+
if (row) this.#emit({ t: "message", message: row });
|
|
408
|
+
}
|
|
409
|
+
|
|
385
410
|
#dropPending(live: LiveSession, decision: PermissionDecision = { outcome: "cancelled" }): void {
|
|
386
411
|
if (!live.pendingPermissions.size) return;
|
|
387
|
-
for (const [requestId, { resolve, timer }] of live.pendingPermissions) {
|
|
412
|
+
for (const [requestId, { resolve, timer, view }] of live.pendingPermissions) {
|
|
388
413
|
clearTimeout(timer);
|
|
389
414
|
resolve({ outcome: decision });
|
|
390
415
|
this.#emit({ t: "permission-resolved", requestId, decision });
|
|
416
|
+
this.#settleRow(live, live.approvalSeq.get(requestId), { ...view, decision });
|
|
417
|
+
live.approvalSeq.delete(requestId);
|
|
391
418
|
}
|
|
392
419
|
live.pendingPermissions.clear();
|
|
393
420
|
}
|
|
@@ -501,7 +528,8 @@ export class SessionManager {
|
|
|
501
528
|
...(opts.system ? { system: true } : {}),
|
|
502
529
|
createdAt: now, modes: null, configOptions: [], commands: [],
|
|
503
530
|
},
|
|
504
|
-
child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(),
|
|
531
|
+
child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(),
|
|
532
|
+
approvalSeq: new Map(), skillNoticeSeq: new Map(), stderrBuf: [],
|
|
505
533
|
background: [], pendingCompletions: [],
|
|
506
534
|
caps: { fork: false, load: false, image: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
|
|
507
535
|
planToken: randomUUID(),
|
|
@@ -686,7 +714,8 @@ export class SessionManager {
|
|
|
686
714
|
usage: (row.usage ?? null) as SessionInfo["usage"],
|
|
687
715
|
commands: normCommands(row.commands),
|
|
688
716
|
},
|
|
689
|
-
child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(),
|
|
717
|
+
child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(),
|
|
718
|
+
approvalSeq: new Map(), skillNoticeSeq: new Map(), stderrBuf: [],
|
|
690
719
|
background: [], pendingCompletions: [],
|
|
691
720
|
caps: { fork: false, load: false, image: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
|
|
692
721
|
planToken: randomUUID(),
|
|
@@ -924,17 +953,26 @@ export class SessionManager {
|
|
|
924
953
|
* reachable in the rail (the old rule showed them under 已归档; the older one dropped them out of
|
|
925
954
|
* the list entirely, which is how a transcript became unreachable while still on disk).
|
|
926
955
|
*
|
|
927
|
-
*
|
|
928
|
-
* has
|
|
929
|
-
cold
|
|
930
|
-
|
|
956
|
+
* Paged **per workspace** (#34), unlike `archived()` which is complete: crash debris accumulates
|
|
957
|
+
* without anyone choosing it, so a cap is still right — but the axis has to be the one the rail
|
|
958
|
+
* groups by. A global top-N let the busiest workspace eat the page, and since a cold row is the
|
|
959
|
+
* only handle that can wake its session, everything it pushed out became unreachable from the UI
|
|
960
|
+
* while the transcript sat fine on disk. See `cold-page.ts`. */
|
|
961
|
+
cold(perWorkspace = COLD_PER_WORKSPACE, totalMax = COLD_TOTAL_MAX): SessionInfo[] {
|
|
962
|
+
return this.coldPage(perWorkspace, totalMax).rows;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** The same page plus `total`/`omitted`, so a caller can say "this is a partial view" instead of
|
|
966
|
+
* presenting a truncated list as the whole cockpit. */
|
|
967
|
+
coldPage(perWorkspace = COLD_PER_WORKSPACE, totalMax = COLD_TOTAL_MAX): ColdPage<SessionInfo> {
|
|
968
|
+
const sorted = this.#store
|
|
931
969
|
.listSessions(true)
|
|
932
970
|
// system rows are the cockpit's own (see list()): never offered as a slot to wake
|
|
933
971
|
.filter((r) => r.archivedAt == null && !r.system && !this.#sessions.has(r.id))
|
|
934
972
|
.map((r) => this.#infoFromRow(r))
|
|
935
973
|
// cold rows by the same rule as live ones (recent chat first), THEN take the page
|
|
936
|
-
.sort(
|
|
937
|
-
|
|
974
|
+
.sort(byActivity);
|
|
975
|
+
return pageCold(sorted, perWorkspace, totalMax);
|
|
938
976
|
}
|
|
939
977
|
|
|
940
978
|
/** One row in the shape list()/cold()/archived() produce, from a stored row. */
|
|
@@ -1239,6 +1277,15 @@ export class SessionManager {
|
|
|
1239
1277
|
rejected = true;
|
|
1240
1278
|
actual = String(s.info.configOptions.find((o) => o.id === configId)?.currentValue ?? "");
|
|
1241
1279
|
}
|
|
1280
|
+
if (configId === "project_skills" && !rejected) {
|
|
1281
|
+
// 决定落在卡上:卡片是「问过」的证据,改配置却不改卡,读起来就像问完没下文。
|
|
1282
|
+
for (const [root, cur] of s.skillNoticeSeq) {
|
|
1283
|
+
if (cur.payload.root && String(cur.payload.root) !== root) continue;
|
|
1284
|
+
const payload = { ...cur.payload, state: actual };
|
|
1285
|
+
cur.payload = payload;
|
|
1286
|
+
this.#settleRow(s, cur.seq, payload);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1242
1289
|
if (rejected) {
|
|
1243
1290
|
this.#emit({
|
|
1244
1291
|
t: "config-rejected",
|
|
@@ -1832,6 +1879,8 @@ export class SessionManager {
|
|
|
1832
1879
|
s.pendingPermissions.delete(requestId);
|
|
1833
1880
|
pending.resolve({ outcome: decision });
|
|
1834
1881
|
this.#emit({ t: "permission-resolved", requestId, decision });
|
|
1882
|
+
this.#settleRow(s, s.approvalSeq.get(requestId), { ...pending.view, decision });
|
|
1883
|
+
s.approvalSeq.delete(requestId);
|
|
1835
1884
|
return true;
|
|
1836
1885
|
}
|
|
1837
1886
|
|
|
@@ -1892,6 +1941,33 @@ export class SessionManager {
|
|
|
1892
1941
|
this.#queueOrWakeCompletion(live, finishedText);
|
|
1893
1942
|
return;
|
|
1894
1943
|
}
|
|
1944
|
+
// Project skills the agent is NOT loading (fork: `_meta.hermes = {kind:"project_skills"}`).
|
|
1945
|
+
// The same door, one more kind. It rides the live status channel on purpose: the sentence is
|
|
1946
|
+
// about THIS open, and persisting it would put 「这个仓库有 N 个 skill 未启用」 above a
|
|
1947
|
+
// conversation from last month — the very thing the status path already refuses to store.
|
|
1948
|
+
const projectSkills = projectSkillsNoticeOf(u);
|
|
1949
|
+
if (projectSkills) {
|
|
1950
|
+
// 一张卡而不是一行闪现的状态:操作者要能在聊天记录里看到「这个仓库有 N 个 skill 没启用」,
|
|
1951
|
+
// 并就地做决定(信任 / 先不)。每个 repo root 只落一次,重复打开不会堆卡片。
|
|
1952
|
+
const rootKey = projectSkills.root || "(no root)";
|
|
1953
|
+
if (!live.skillNoticeSeq.has(rootKey)) {
|
|
1954
|
+
const payload: Record<string, unknown> = {
|
|
1955
|
+
root: projectSkills.root,
|
|
1956
|
+
count: projectSkills.count,
|
|
1957
|
+
text: projectSkills.text,
|
|
1958
|
+
state: "ask",
|
|
1959
|
+
};
|
|
1960
|
+
const row = this.#store.appendMessage({
|
|
1961
|
+
sessionId: live.info.id,
|
|
1962
|
+
kind: "project-skills",
|
|
1963
|
+
payload,
|
|
1964
|
+
createdAt: Date.now(),
|
|
1965
|
+
});
|
|
1966
|
+
live.skillNoticeSeq.set(rootKey, { seq: row.seq, payload });
|
|
1967
|
+
this.#emit({ t: "message", message: row });
|
|
1968
|
+
}
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1895
1971
|
const statusText = statusLineOf(u);
|
|
1896
1972
|
if (statusText) {
|
|
1897
1973
|
const statusKind = statusKindOf(u);
|
|
@@ -2074,6 +2150,18 @@ export class SessionManager {
|
|
|
2074
2150
|
outcome: { outcome: "selected", optionId: remembered.optionId } as never,
|
|
2075
2151
|
});
|
|
2076
2152
|
}
|
|
2153
|
+
// 落一行再弹卡(操作者 2026-10-19):审批只以 transient 事件存在时,弹窗关掉/刷新/换设备之后
|
|
2154
|
+
// 就什么都不剩了 —— 「刚才那个请求我批的什么」无从查起。行在请求到达这一刻写,决定稍后补进
|
|
2155
|
+
// 同一行(#settleRow),于是聊天记录里留下一条带结论的记录。
|
|
2156
|
+
const approvalRow = this.#store.appendMessage({
|
|
2157
|
+
sessionId: live.info.id,
|
|
2158
|
+
kind: "approval",
|
|
2159
|
+
payload: { ...view, decision: null },
|
|
2160
|
+
createdAt: view.createdAt,
|
|
2161
|
+
});
|
|
2162
|
+
live.approvalSeq.set(requestId, approvalRow.seq);
|
|
2163
|
+
this.#touch(live, approvalRow.createdAt);
|
|
2164
|
+
this.#emit({ t: "message", message: approvalRow });
|
|
2077
2165
|
this.#emit({ t: "permission", request: view });
|
|
2078
2166
|
return new Promise<RequestPermissionResponse>((resolve) => {
|
|
2079
2167
|
const timer = setTimeout(() => {
|
|
@@ -2081,6 +2169,9 @@ export class SessionManager {
|
|
|
2081
2169
|
const d: PermissionDecision = { outcome: "cancelled" };
|
|
2082
2170
|
resolve({ outcome: d });
|
|
2083
2171
|
this.#emit({ t: "permission-resolved", requestId, decision: d });
|
|
2172
|
+
// 超时也是「有结论」:卡片上要留下「没人答,已取消」,不能一直挂着等批准。
|
|
2173
|
+
this.#settleRow(live, live.approvalSeq.get(requestId), { ...view, decision: d });
|
|
2174
|
+
live.approvalSeq.delete(requestId);
|
|
2084
2175
|
}, PERMISSION_TIMEOUT_MS);
|
|
2085
2176
|
live.pendingPermissions.set(requestId, { view, resolve, timer });
|
|
2086
2177
|
});
|
|
@@ -2159,10 +2250,17 @@ export class SessionManager {
|
|
|
2159
2250
|
/** The cockpit CLI's read view (issue #15): the sessions the OPERATOR can see, in one list —
|
|
2160
2251
|
* live and cold, never 已归档 (that is the operator's shelf, not work in flight).
|
|
2161
2252
|
*
|
|
2162
|
-
* Cold rows are
|
|
2163
|
-
*
|
|
2253
|
+
* Cold rows are paged per workspace by `cold()`, deeper than the rail's own expand depth: an agent
|
|
2254
|
+
* asking "what is going on" is not browsing, and it filters by `--workspace` on top — but it still
|
|
2255
|
+
* must not receive a scrollback of every session this install ever made. */
|
|
2164
2256
|
cockpitRows(): SessionInfo[] {
|
|
2165
|
-
return [...this.list(), ...this.cold(
|
|
2257
|
+
return [...this.list(), ...this.cold(COLD_PER_WORKSPACE_COCKPIT)];
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/** The cockpit's cold page together with what it left out, so `session list` can report
|
|
2261
|
+
* `capped` honestly instead of presenting a truncated view as the whole cockpit (#34). */
|
|
2262
|
+
cockpitCold(): ColdPage<SessionInfo> {
|
|
2263
|
+
return this.coldPage(COLD_PER_WORKSPACE_COCKPIT);
|
|
2166
2264
|
}
|
|
2167
2265
|
|
|
2168
2266
|
/** Approvals a session is WAITING on right now — the one fact worth waking another agent for. */
|
|
@@ -2569,6 +2667,28 @@ function hermesPayload(u: Record<string, unknown>): Record<string, unknown> {
|
|
|
2569
2667
|
return meta?.hermes ?? {};
|
|
2570
2668
|
}
|
|
2571
2669
|
|
|
2670
|
+
/** Untrusted project skills the agent reported at session open, or null.
|
|
2671
|
+
*
|
|
2672
|
+
* Shape (fork `acp_adapter/server.py::_notify_project_skills`): empty thought chunk with
|
|
2673
|
+
* `_meta.hermes = {kind:"project_skills", state:"untrusted", root, skillCount, text}`. The agent
|
|
2674
|
+
* sends it only when the repo actually carries skills AND nobody has decided yet — so a plain
|
|
2675
|
+
* checkout never produces one, and an unknown kind is still ignored silently (that is what lets
|
|
2676
|
+
* the two repositories ship independently).
|
|
2677
|
+
*/
|
|
2678
|
+
function projectSkillsNoticeOf(
|
|
2679
|
+
u: Record<string, unknown>,
|
|
2680
|
+
): { text: string; root: string; count: number } | null {
|
|
2681
|
+
if (hermesKindOf(u) !== "project_skills") return null;
|
|
2682
|
+
const payload = hermesPayload(u);
|
|
2683
|
+
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
2684
|
+
if (!text) return null;
|
|
2685
|
+
return {
|
|
2686
|
+
text,
|
|
2687
|
+
root: typeof payload.root === "string" ? payload.root : "",
|
|
2688
|
+
count: typeof payload.skillCount === "number" ? payload.skillCount : 0,
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2572
2692
|
/** The completion sentence a finished background process produced (#22), or null. */
|
|
2573
2693
|
function completionTextOf(u: Record<string, unknown>): string | null {
|
|
2574
2694
|
if (hermesKindOf(u) !== "completion") return null;
|
|
@@ -195,6 +195,10 @@ export interface ListQuery {
|
|
|
195
195
|
/** bucket the page instead of returning it flat. `workspace` is the only dimension today because it
|
|
196
196
|
* is the one the operator's rail groups by — anything else would be an invented taxonomy. */
|
|
197
197
|
by?: "workspace";
|
|
198
|
+
/** How many cold rows the caller's own page left out (issue #34). Set by the route from
|
|
199
|
+
* `cockpitCold()`: absent/0 means "you are looking at everything", non-zero means the view is
|
|
200
|
+
* partial and the answer must say so. Never a wire parameter. */
|
|
201
|
+
coldOmitted?: number;
|
|
198
202
|
/** fixed clock for tests; never set by a caller over the wire */
|
|
199
203
|
now?: number;
|
|
200
204
|
}
|
|
@@ -207,7 +211,12 @@ export interface ListResult {
|
|
|
207
211
|
meta: {
|
|
208
212
|
total: number;
|
|
209
213
|
returned: number;
|
|
214
|
+
/** TRUE means "this is not the whole answer" — either the list limit cut it, or (`cold_omitted`)
|
|
215
|
+
* the cold page itself did. Before #34 only the first was visible, so an agent could not tell a
|
|
216
|
+
* complete view from a truncated one. */
|
|
210
217
|
capped: boolean;
|
|
218
|
+
/** cold rows that existed but never reached this function (0 = nothing was hidden) */
|
|
219
|
+
cold_omitted: number;
|
|
211
220
|
/** the filters that were actually applied, echoed back so a caller cannot misread an empty list
|
|
212
221
|
* as "there is nothing" when what happened is "your filter matched nothing" */
|
|
213
222
|
applied: Record<string, string | number | boolean>;
|
|
@@ -308,7 +317,10 @@ export function listSessions(rows: CockpitSessionRow[], q: ListQuery = {}): List
|
|
|
308
317
|
const limit = Math.max(1, Math.min(LIST_LIMIT_MAX, Math.round(q.limit ?? LIST_LIMIT_DEFAULT)));
|
|
309
318
|
applied.limit = limit;
|
|
310
319
|
const total = out.length;
|
|
311
|
-
const
|
|
320
|
+
const coldOmitted = Math.max(0, Math.floor(q.coldOmitted ?? 0));
|
|
321
|
+
// `capped` answers "can I trust this as the whole picture?", so a cold page that dropped rows counts
|
|
322
|
+
// even when the list limit itself did not bite (#34).
|
|
323
|
+
const capped = total > limit || coldOmitted > 0;
|
|
312
324
|
const page = out.slice(0, limit);
|
|
313
325
|
|
|
314
326
|
let groups: ListResult["groups"];
|
|
@@ -331,7 +343,7 @@ export function listSessions(rows: CockpitSessionRow[], q: ListQuery = {}): List
|
|
|
331
343
|
}));
|
|
332
344
|
}
|
|
333
345
|
|
|
334
|
-
return { rows: page, ...(groups ? { groups } : {}), meta: { total, returned: Math.min(total, limit), capped, applied } };
|
|
346
|
+
return { rows: page, ...(groups ? { groups } : {}), meta: { total, returned: Math.min(total, limit), capped, cold_omitted: coldOmitted, applied } };
|
|
335
347
|
}
|
|
336
348
|
|
|
337
349
|
function normPath(p: string): string {
|
|
@@ -635,7 +635,9 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
|
635
635
|
const isOperator = !caller && auth.verifyMachineToken(/^Bearer\s+(.+)$/i.exec(String(req.headers.authorization ?? ""))?.[1] ?? undefined);
|
|
636
636
|
if (!caller && !isOperator) return send(res, 403, { error: COCKPIT_ERRORS.badToken });
|
|
637
637
|
// Scope: what the operator can see. 已归档 is out, on purpose — see `cockpitRows`.
|
|
638
|
-
|
|
638
|
+
// The cold page is taken once so its `omitted` count can travel with the answer (#34).
|
|
639
|
+
const cold = mgr.cockpitCold();
|
|
640
|
+
const rows = [...mgr.list(), ...cold.rows];
|
|
639
641
|
const rowOf = (id: string): SessionInfo | null => rows.find((s) => s.id === id) ?? null;
|
|
640
642
|
const summarize = (s: SessionInfo) => sessionRow(s, {
|
|
641
643
|
plan: store.getPlan(s.id),
|
|
@@ -673,6 +675,7 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
|
673
675
|
all: sp.get("all") === "1" || sp.get("all") === "true",
|
|
674
676
|
includeSelf: sp.get("include_self") === "1" || sp.get("include_self") === "true",
|
|
675
677
|
by: by as ListQuery["by"],
|
|
678
|
+
coldOmitted: cold.omitted,
|
|
676
679
|
};
|
|
677
680
|
if (query.sort && !["last", "created", "name"].includes(query.sort)) {
|
|
678
681
|
return send(res, 400, { error: COCKPIT_ERRORS.badRequest, message: `--sort wants last|created|name, got "${query.sort}"` });
|
|
@@ -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
|
*
|
|
@@ -435,8 +435,12 @@ export interface StoredMessage {
|
|
|
435
435
|
/** `note` = a 「状态行」 that is not a message: 「recalled 8 memories」 / 「compacting」 / a warning.
|
|
436
436
|
* Deliberately a transcript row (not a separate table): its position IS the seq — after the
|
|
437
437
|
* operator's sentence, before the reply — and the page already orders, pages and replays by seq.
|
|
438
|
-
* `meta` stays what it was: a line the HOST says (a rejected send), never persisted.
|
|
439
|
-
|
|
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";
|
|
440
444
|
payload: unknown; // raw ACP content/update fragment (kept lossless)
|
|
441
445
|
toolCallId?: string; // set for kind=tool; upsert key
|
|
442
446
|
createdAt: number;
|