agentus 0.6.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.
@@ -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), and a
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
@@ -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 自己接着干,而不是回合就这么结束),以及 **DeepSeek 的 web 后端**
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 分支上,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.6.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": {
@@ -59,6 +59,7 @@
59
59
  "terminal-palette": "tsx scripts/qa/terminal-palette.mts",
60
60
  "android-trust-merge": "node scripts/qa/android-trust-merge.mjs",
61
61
  "cards-persist": "node scripts/qa/cards-persist.mjs",
62
+ "cold-paging": "tsx scripts/qa/cold-paging.mts",
62
63
  "plan-hint-sweep": "node scripts/qa/plan-hint-sweep.mjs",
63
64
  "plan-service-sweep": "node scripts/qa/plan-service-sweep.mjs",
64
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";
@@ -945,17 +953,26 @@ export class SessionManager {
945
953
  * reachable in the rail (the old rule showed them under 已归档; the older one dropped them out of
946
954
  * the list entirely, which is how a transcript became unreachable while still on disk).
947
955
  *
948
- * Capped, unlike `archived()`: crash debris accumulates without anyone choosing it, and the rail
949
- * has its own recent-N rule anyway. */
950
- cold(limit = 20): SessionInfo[] {
951
- return this.#store
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
952
969
  .listSessions(true)
953
970
  // system rows are the cockpit's own (see list()): never offered as a slot to wake
954
971
  .filter((r) => r.archivedAt == null && !r.system && !this.#sessions.has(r.id))
955
972
  .map((r) => this.#infoFromRow(r))
956
973
  // cold rows by the same rule as live ones (recent chat first), THEN take the page
957
- .sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt)
958
- .slice(0, limit);
974
+ .sort(byActivity);
975
+ return pageCold(sorted, perWorkspace, totalMax);
959
976
  }
960
977
 
961
978
  /** One row in the shape list()/cold()/archived() produce, from a stored row. */
@@ -2233,10 +2250,17 @@ export class SessionManager {
2233
2250
  /** The cockpit CLI's read view (issue #15): the sessions the OPERATOR can see, in one list —
2234
2251
  * live and cold, never 已归档 (that is the operator's shelf, not work in flight).
2235
2252
  *
2236
- * Cold rows are capped by `cold()`'s own page size on purpose: an agent asking "what is going on"
2237
- * gets the recent cockpit, not a scrollback of every session this install ever made. */
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. */
2238
2256
  cockpitRows(): SessionInfo[] {
2239
- return [...this.list(), ...this.cold(50)];
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);
2240
2264
  }
2241
2265
 
2242
2266
  /** Approvals a session is WAITING on right now — the one fact worth waking another agent for. */
@@ -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 capped = total > limit;
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
- const rows = mgr.cockpitRows();
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}"` });