agentus 0.3.9 → 0.4.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 CHANGED
@@ -49,10 +49,11 @@ first, else `newSession` fails with `-32000`). Without one the cockpit still run
49
49
  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
- depth**, a permission mode that survives a restart, the **context-budget** field — come from the
53
- fork's integration build instead. That is a five-line install:
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) — come from the fork's integration
54
+ build instead. That is a five-line install:
54
55
  [§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
55
- `agentus/v0.22.0/merge-20261015-0014`, on Hermes `v0.22.0`.
56
+ `agentus/v0.22.0/merge-20261017-2152`, on Hermes `v0.22.0`.
56
57
 
57
58
  The first slot you start will use the Hermes home **your own agent already uses**
58
59
  (`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
@@ -110,6 +111,7 @@ layer never changes.
110
111
  |---|---|---|
111
112
  | `AGENTUS_PORT` | `8788` | server port (never reads bare `PORT` — that name is polluted on shared hosts) |
112
113
  | `AGENTUS_DATA` | `~/.agentus` | where state lives; see "Where your state lives" for the full order (`<repo>/packages/server/.data` on a checkout that already has one) |
114
+ | `AGENTUS_TITLE` | `Agentus` | this instance's own name: the browser tab title and the PWA/home-screen label. Set it when one person runs several cockpits (`AGENTUS_TITLE="Agentus(pre-betabox)"`) |
113
115
  | `AGENTUS_HERMES_CMD` | `hermes` | binary to spawn for the hermes backend |
114
116
  | `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | ditto for qoder |
115
117
  | `AGENTUS_HERMES_HOME` | `~/.hermes` | default `HERMES_HOME` for a row that does not name one (the operator's own home) |
@@ -277,6 +279,40 @@ A row that points at its own home needs no permission — only a name. One trap
277
279
  a row points elsewhere: a fresh home whose `hindsight/config.json` keeps the default profile
278
280
  name `"hermes"` would attach to your production memory daemon, so the row warns when it would.
279
281
 
282
+ ## The cockpit CLI (for the agents inside it)
283
+
284
+ An agent in a slot is blind: the other sessions, what they are doing, and what the operator
285
+ asked them all live in the cockpit, not in the agent's prompt. `agentus cockpit` is the one
286
+ channel that lets an agent read that — and, if you allow it, hand a message to another session:
287
+
288
+ ```bash
289
+ "$AGENTUS_COCKPIT_BIN" capabilities # the contract, self-describing (start here)
290
+ "$AGENTUS_COCKPIT_BIN" session list --here # …narrowed: my workspace, newest activity first
291
+ "$AGENTUS_COCKPIT_BIN" session list --waiting --table # …the operator's approval queue, as a table
292
+ "$AGENTUS_COCKPIT_BIN" session rename <id> "…name…" # name a session (the row records who did it)
293
+ "$AGENTUS_COCKPIT_BIN" session show <id> # one session, + what it is waiting on
294
+ "$AGENTUS_COCKPIT_BIN" session messages <id> --tail 20
295
+ "$AGENTUS_COCKPIT_BIN" session plan <id>
296
+ "$AGENTUS_COCKPIT_BIN" session send <id> "…a message…" # operator-gated, off by default
297
+ "$AGENTUS_COCKPIT_BIN" skill # this CLI's own manual, from the package
298
+ ```
299
+
300
+ The shape is noun-then-verb (`agentus session send <id> …`) because every one of these acts on the
301
+ same object; the session id is a positional argument, options stay options.
302
+
303
+ The cockpit hands `AGENTUS_COCKPIT_BIN/_ENDPOINT/_TOKEN/_SESSION` to each agent it spawns, so
304
+ the CLI needs no arguments, no port and no config — and the token never appears on a command
305
+ line. `AGENTUS_COCKPIT_BIN` is this same `agentus` launcher, so there is one command with one
306
+ name; `agentus cockpit …` at your own shell is the identical thing. Delivering a message (**设置 → 驾驶舱互发消息**, off by default) means the target agent
307
+ *received* it, never that it read or agreed; a busy target refuses with `target_busy` and saw
308
+ nothing.
309
+
310
+ The manual is `skills/agentus-cockpit/SKILL.md`, shipped in the package. Backends with a native
311
+ skills directory (Hermes: `$HERMES_HOME/skills`) get it installed at spawn — idempotently, by
312
+ content, so a release that changes it refreshes it and a spawn that would not costs nothing.
313
+ Backends without one (Qoder) get a single line in the plan reminder and can read the same file
314
+ with `agentus cockpit skill`, which is also why the document is never pasted into a prompt.
315
+
280
316
  ## What it does today (M0 → M4)
281
317
 
282
318
  - multi-session rail, **grouped by working directory** — running sessions and **closed
package/README.zh-CN.md CHANGED
@@ -44,9 +44,10 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
44
44
  照样能跑、照样能登录 —— 只是没有后端可以开会话。
45
45
 
46
46
  裸装的 Hermes 就能跑会话;驾驶舱自己的那几项能力 —— 按会话切换的**思考深度**、跨重启存活的**权限模式**、
47
- **上下文预算**输入框 —— 来自 fork 的集成构建(tag)。装法就五行:
47
+ **上下文预算**输入框、把记忆召回 / 存记忆提示送进消息流的 **status 通道** —— 来自 fork 的集成构建(tag)。
48
+ 装法就五行:
48
49
  [§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
49
- `agentus/v0.22.0/merge-20261015-0014`,基座 Hermes `v0.22.0`。
50
+ `agentus/v0.22.0/merge-20261017-2152`,基座 Hermes `v0.22.0`。
50
51
 
51
52
  你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
52
53
  指定了另一个 —— 见下面的「HERMES_HOME」。
@@ -240,6 +241,37 @@ Hermes 内置的 SQLite 3.50.4(落在 WAL-reset 区间内)在两个写入者
240
241
  `hindsight/config.json` 保持默认 profile 名 `"hermes"` 的话,会接上你的生产记忆守护进程,所以这种
241
242
  情况下那一行会给出警告。
242
243
 
244
+ ## 驾驶舱 CLI(给里面的 agent 用)
245
+
246
+ 槽位里的 agent 是瞎子:别的会话在干什么、你让它改什么,都在驾驶舱里,不在它的 prompt 里。
247
+ `agentus cockpit` 是唯一一条让 agent 读到这些的通道 —— 以及(你允许时)把一句话传给另一个会话:
248
+
249
+ ```bash
250
+ "$AGENTUS_COCKPIT_BIN" capabilities # 契约本身,自述(先跑这个)
251
+ "$AGENTUS_COCKPIT_BIN" session list --here # 收窄:我的工作空间,按活跃时间倒序
252
+ "$AGENTUS_COCKPIT_BIN" session list --waiting --table # 操作者的待审批队列,打成表
253
+ "$AGENTUS_COCKPIT_BIN" session rename <id> "…名字…" # 给会话改名(行里会记下是谁改的)
254
+ "$AGENTUS_COCKPIT_BIN" session show <id> # 单个会话,外加它此刻在等什么
255
+ "$AGENTUS_COCKPIT_BIN" session messages <id> --tail 20
256
+ "$AGENTUS_COCKPIT_BIN" session plan <id>
257
+ "$AGENTUS_COCKPIT_BIN" session send <id> "…一句话…" # 需要操作者开启,默认关闭
258
+ "$AGENTUS_COCKPIT_BIN" skill # 这份 CLI 的说明书,直接从包里取
259
+ ```
260
+
261
+ 形状是「名词 + 动词」(`agentus session send <id> …`),因为这些动作都作用在同一个对象上;
262
+ 会话 id 是位置参数,选项才用 `--`。
263
+
264
+ 身份由驾驶舱在 spawn 时注入(`AGENTUS_COCKPIT_BIN/_ENDPOINT/_TOKEN/_SESSION`),所以 CLI
265
+ 不要参数、不要端口、不要配置,token 也不会出现在命令行上。`AGENTUS_COCKPIT_BIN` 就是同一个
266
+ `agentus` 启动器 —— 命令只有一个、名字也只有一个,你在自己 shell 里敲 `agentus cockpit …` 是同一件事。互发消息(**设置 → 驾驶舱互发消息**,
267
+ 默认关闭)里的「已送达」只意味着对方 agent **收到**并开始一轮,绝不等于它读懂了或同意了;
268
+ 对方正在跑回合时会拒绝并回 `target_busy`,那条消息哪里都没进。
269
+
270
+ 说明书就是 `skills/agentus-cockpit/SKILL.md`,随包发布。有原生技能目录的后端(Hermes:
271
+ `$HERMES_HOME/skills`)在 spawn 时装进去 —— 按内容幂等,所以新版本会刷新、重复 spawn 不产生写盘;
272
+ 没有原生目录的后端(Qoder)只在计划提示里拿一行,需要时用 `agentus cockpit skill` 读同一份文件,
273
+ 这也正是它永远不被塞进 prompt 的原因。
274
+
243
275
  ## 目前能做什么(M0 → M4)
244
276
 
245
277
  - 多会话侧栏,**按工作目录分组** —— 运行中的会话和**已关闭的案例**(进程已退出的 transcript;
package/bin/agentus.mjs CHANGED
@@ -44,6 +44,18 @@ Options
44
44
  -h, --help this text
45
45
  -v, --version print the version
46
46
 
47
+ Commands (client side — they need the identity the cockpit gives an agent it spawns)
48
+ agentus session list who you are + every session in scope
49
+ agentus session show <id> one session in detail + any approval it waits on
50
+ agentus session messages <id> [--tail N]
51
+ agentus session plan <id>
52
+ agentus session send <id> <text…> deliver a message into another session (operator-gated)
53
+ agentus capabilities the CLI's contract, self-describing
54
+ agentus skill [--path] the CLI's manual, as shipped in the package
55
+
56
+ Inside a session they are called as: "$AGENTUS_COCKPIT_BIN" session list
57
+ The cockpit hands that path (this very file) plus endpoint/token/session in the environment.
58
+
47
59
  The server reads more environment variables than these (agent backends, auth,
48
60
  speech). They are listed in the README, and the boot log prints the ones in play.
49
61
 
@@ -67,6 +79,21 @@ function fail(message) {
67
79
  }
68
80
  }
69
81
 
82
+ // Client subcommands, not flags: hand the rest of the argv to the CLI in its own process, so this file
83
+ // keeps holding no product logic. Top-level await keeps the dispatch from indenting the whole launcher
84
+ // into an `else` block.
85
+ //
86
+ // `session` is the noun the five session-scoped verbs hang off; `capabilities` and `skill` sit at the
87
+ // top level because they describe the CLI itself, not a session.
88
+ const CLIENT_NOUNS = new Set(["session", "capabilities", "skill"]);
89
+ if (CLIENT_NOUNS.has(process.argv[2])) {
90
+ const child = spawn(process.execPath, [path.join(PKG_ROOT, "bin/cli.mjs"), ...process.argv.slice(2)], {
91
+ stdio: "inherit",
92
+ });
93
+ const code = await new Promise((resolve) => child.on("exit", (c, signal) => resolve(signal ? 130 : c ?? 0)));
94
+ process.exit(code);
95
+ }
96
+
70
97
  const opts = { port: undefined, data: undefined, open: false, where: false };
71
98
  const argv = process.argv.slice(2);
72
99
  for (let i = 0; i < argv.length; i++) {
package/bin/cli.mjs ADDED
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `agentus <command>` — the CLI client: sessions, their transcripts and plans, and the gated
4
+ * cross-session `send`. Dispatched by `bin/agentus.mjs`, which is also what the cockpit hands agents
5
+ * as `AGENTUS_COCKPIT_BIN`, so there is exactly one command and one spelling everywhere.
6
+ *
7
+ * SHAPE: noun, then verb — `agentus session list|show|messages|plan|send`
8
+ * The five things an agent needs are all about ONE object (a session), so the noun is not decoration:
9
+ * it keeps `list` from meaning "list what?" and leaves the top level free for the two commands that
10
+ * are NOT about a session — `capabilities` (this CLI's contract) and `skill` (its manual). The
11
+ * session id is a POSITIONAL argument, like `docker logs <id>` / `git show <rev>`: it is the object
12
+ * the verb acts on, not an option, and `--tail` stays an option exactly because it is a modifier.
13
+ *
14
+ * IDENTITY AND SECRETS
15
+ * The cockpit sets `AGENTUS_COCKPIT_ENDPOINT` / `_TOKEN` / `_SESSION` in the agent's own environment
16
+ * when it spawns it; this file only reads them. NEVER echo/export/print them: one is your session, the
17
+ * other is the key to it. Nothing here takes a token on the command line — a command line ends up in
18
+ * `ps` and in shell history, an environment does not.
19
+ *
20
+ * OUTPUT CONTRACT
21
+ * stdout carries ONE JSON document: `{ success, data, meta }`. Anything a human reads goes to stderr,
22
+ * so a caller can pipe stdout straight into `jq`. A refusal is `{ success: false, error: { code,
23
+ * message } }` on stdout plus a non-zero exit; the code is a STABLE string (`target_busy`,
24
+ * `sends_disabled`, …) that a model can branch on.
25
+ */
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import path from "node:path";
28
+ import process from "node:process";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ /** This package's root — `bin/` is one level down. Resolved from the FILE, never from cwd: the CLI is
32
+ * run from inside an agent's session, whose cwd is the operator's project. */
33
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
34
+
35
+ const ENDPOINT = (process.env.AGENTUS_COCKPIT_ENDPOINT ?? "").replace(/\/+$/, "");
36
+ const TOKEN = process.env.AGENTUS_COCKPIT_TOKEN ?? "";
37
+ const SELF = process.env.AGENTUS_COCKPIT_SESSION ?? "";
38
+
39
+ const USAGE = `agentus — read the cockpit from inside a session, and (if the operator allowed it) talk to
40
+ another one
41
+
42
+ agentus session list [flags] who you are + the sessions in scope that match
43
+ --here only MY workspace (usually what you want)
44
+ --workspace <p> only that workspace --status running|ready|cold
45
+ --waiting only sessions with an approval outstanding (the operator's queue)
46
+ --since <when> activity within 30m/2h/1d/2w or 2026-10-17[T14:30]
47
+ --created-since <when> same grammar, measured against CREATION
48
+ --grep <text> title substring --sort last|created|name
49
+ --limit N default 20, max 200 --all include 已归档 --include-self
50
+ --by workspace bucket the page by workspace (groups + sessions)
51
+ --table print a HUMAN table instead of JSON (for people, not for pipes)
52
+ agentus session rename <id> <title…> | --auto name it (attributed to you)
53
+ agentus session show <id> one session in detail, with any approval it is waiting on
54
+ agentus session messages <id> [--tail N]
55
+ the last N messages, each cut to the documented size
56
+ agentus session plan <id> the plan object the operator's card renders
57
+ agentus session send <id> <text…> deliver a message into another session (stdin if no text)
58
+
59
+ agentus capabilities the contract: commands, fields, truncation, scope, send state
60
+ agentus skill [--path] this CLI's manual, as shipped in the package
61
+
62
+ Operator mode: run outside a session with AGENTUS_DATA (data dir) and AGENTUS_PORT (or
63
+ AGENTUS_ENDPOINT) pointing at your cockpit — the CLI then authenticates with <data>/auth.token, the
64
+ same credential scripts already use. Inside a session it always prefers the cockpit's own variables.
65
+
66
+ Read "capabilities" first: it is self-describing, so no doc can drift from the code.
67
+ "send" is OFF unless the operator turned 驾驶舱互发消息 on. A busy target answers target_busy and saw
68
+ NOTHING — never report that as delivered, and never claim another session READ your message.
69
+ `;
70
+
71
+ function out(doc) {
72
+ process.stdout.write(`${JSON.stringify(doc)}\n`);
73
+ }
74
+
75
+ function fail(code, message, extra = {}) {
76
+ out({ success: false, error: { code, message }, ...extra });
77
+ process.stderr.write(`error [${code}]: ${message}\n`);
78
+ process.exit(1);
79
+ }
80
+
81
+ /** Operator mode — a human at a shell, not an agent inside a session.
82
+ *
83
+ * The cockpit already trusts `<DATA_DIR>/auth.token` as the operator credential (it is what scripts
84
+ * and CI use), so the same CLI works from a terminal without inventing a second door. Read from the
85
+ * FILE, never from argv: the token identifies the operator. Returns null when this is an agent (its
86
+ * own variables win) or when there is nothing to authenticate with. */
87
+ function resolveOperator() {
88
+ if (ENDPOINT && TOKEN && SELF) return null; // an agent: its identity is the cockpit's to give
89
+ const dataDir = process.env.AGENTUS_DATA || path.join(process.env.HOME ?? "", ".agentus");
90
+ const file = path.join(dataDir, "auth.token");
91
+ if (!existsSync(file)) return null;
92
+ const port = process.env.AGENTUS_PORT || "8788";
93
+ const endpoint = (process.env.AGENTUS_ENDPOINT || `http://127.0.0.1:${port}`).replace(/\/+$/, "");
94
+ const token = readFileSync(file, "utf8").trim();
95
+ return token ? { endpoint, token } : null;
96
+ }
97
+
98
+ const OPERATOR = resolveOperator();
99
+ const EP = ENDPOINT || OPERATOR?.endpoint || "";
100
+
101
+ function headers(json = false) {
102
+ const h = {};
103
+ if (json) h["content-type"] = "application/json";
104
+ if (TOKEN) h["x-agentus-cockpit-token"] = TOKEN;
105
+ else if (OPERATOR) h.authorization = `Bearer ${OPERATOR.token}`;
106
+ return h;
107
+ }
108
+
109
+ function needAuth() {
110
+ if (!EP || (!TOKEN && !OPERATOR)) {
111
+ fail(
112
+ "no_identity",
113
+ "no cockpit to talk to: either run this inside an Agentus session (the cockpit hands the agent AGENTUS_COCKPIT_ENDPOINT/_TOKEN/_SESSION), or from a shell with AGENTUS_DATA pointing at a cockpit whose auth.token you own. Keep your own notes instead.",
114
+ );
115
+ }
116
+ }
117
+
118
+ async function call(pathname) {
119
+ let res;
120
+ try {
121
+ res = await fetch(`${EP}${pathname}`, { headers: headers() });
122
+ } catch (e) {
123
+ fail("unreachable", `the cockpit did not answer: ${e?.message ?? e}`);
124
+ }
125
+ const body = await res.json().catch(() => null);
126
+ if (!res.ok) fail(String(body?.error ?? `http_${res.status}`), String(body?.error ?? `the cockpit answered HTTP ${res.status}`));
127
+ return body;
128
+ }
129
+
130
+ async function readStdin() {
131
+ const chunks = [];
132
+ for await (const c of process.stdin) chunks.push(c);
133
+ return Buffer.concat(chunks).toString("utf8").trim();
134
+ }
135
+
136
+ /** Age in the smallest unit that still reads: 12s / 7m / 3h / 5d. Stale-but-live is the case this is
137
+ * for — a session waiting on an approval shows an old age and that is the signal, not a bug. */
138
+ function age(ms) {
139
+ const s = Math.max(0, Math.round(ms / 1000));
140
+ if (s < 60) return `${s}s`;
141
+ if (s < 3600) return `${Math.round(s / 60)}m`;
142
+ if (s < 86400) return `${Math.round(s / 3600)}h`;
143
+ return `${Math.round(s / 86400)}d`;
144
+ }
145
+
146
+ function tableRow(s, now) {
147
+ const dot = s.cold ? "○" : "●";
148
+ const flags = [s.permissions ? `⏸${s.permissions}` : "", s.plan ? `▶${s.plan.done ?? ""}` : "", s.self ? "self" : ""]
149
+ .filter(Boolean)
150
+ .join(" ");
151
+ const name = String(s.name ?? "").slice(0, 44);
152
+ return `${dot} ${age(now - (s.lastAt ?? now)).padStart(4)} ${flags.padEnd(8)} ${s.id} ${name}`;
153
+ }
154
+
155
+ /** Grouped when the answer has groups, flat otherwise — one renderer, so `--table` never disagrees with
156
+ * `--by workspace` about what the shape of the answer is. */
157
+ function renderTable(data) {
158
+ const now = Date.now();
159
+ const self = data?.self;
160
+ const head = [
161
+ self ? `you: ${self.id} ${self.name ?? ""}` : "you: (operator — no session identity)",
162
+ `${data?.meta?.returned ?? 0}/${data?.meta?.total ?? 0} session(s)${data?.meta?.capped ? ", capped" : ""}`,
163
+ data?.meta?.applied ? `filters: ${JSON.stringify(data.meta.applied)}` : "",
164
+ ].filter(Boolean).join(" · ");
165
+ const lines = [head, ""];
166
+ const groups = data?.groups;
167
+ if (groups) {
168
+ for (const g of groups) {
169
+ lines.push(`${g.key} (${g.sessions.length})`);
170
+ for (const s of g.sessions) lines.push(` ${tableRow(s, now)}`);
171
+ lines.push("");
172
+ }
173
+ } else {
174
+ for (const s of data?.sessions ?? []) lines.push(tableRow(s, now));
175
+ lines.push("");
176
+ }
177
+ lines.push("● live ○ cold ⏸ waiting on approval ▶ plan");
178
+ return `${lines.join("\n")}\n`;
179
+ }
180
+
181
+ const [cmd, ...rest] = process.argv.slice(2);
182
+ if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
183
+ process.stderr.write(USAGE);
184
+ process.exit(cmd ? 0 : 2);
185
+ }
186
+
187
+ // `skill` answers from the package, with no cockpit involved: the document ships next to this file
188
+ // (`skills/agentus-cockpit/SKILL.md`), so an agent whose backend has no native skill discovery can still
189
+ // read it on demand instead of having it pasted into every prompt. Keep it OUTSIDE the auth gate —
190
+ // needing a token to learn how to use the token would be a locked door with the key behind it.
191
+ if (cmd === "skill") {
192
+ const doc = path.join(PKG_ROOT, "skills/agentus-cockpit/SKILL.md");
193
+ if (rest.includes("--path")) {
194
+ process.stdout.write(`${doc}\n`);
195
+ process.exit(0);
196
+ }
197
+ if (!existsSync(doc)) fail("no_skill", `the skill document is missing from this install (expected ${doc})`);
198
+ process.stdout.write(readFileSync(doc, "utf8"));
199
+ process.exit(0);
200
+ }
201
+
202
+ needAuth();
203
+ const q = (s) => encodeURIComponent(s);
204
+ const meta = (data) => ({ schema_version: data?.schema_version ?? 1, ...(data?.meta ?? {}) });
205
+
206
+ if (cmd === "capabilities") {
207
+ const data = await call("/api/cockpit/capabilities");
208
+ out({ success: true, data, meta: meta(data) });
209
+ } else if (cmd === "session") {
210
+ const [verb, ...args] = rest;
211
+ const id = args[0];
212
+
213
+ if (verb === "list") {
214
+ // Flags only after the verb, and the id-less list is the one command where that is the whole
215
+ // surface: everything here narrows WHICH sessions answer, nothing changes them.
216
+ const params = new URLSearchParams();
217
+ // `by` and `table` are display choices: `by` changes the JSON shape (the server buckets), `table`
218
+ // only changes what this process prints — it never travels as a query the server could act on.
219
+ const VALUE_FLAGS = ["workspace", "status", "since", "created-since", "grep", "sort", "limit", "by"];
220
+ const BOOL_FLAGS = ["here", "waiting", "all", "include-self", "table"];
221
+ // flags start at 0 here: `session list` takes no positional id (unlike show/messages/plan)
222
+ for (let i = 0; i < args.length; i++) {
223
+ const a = args[i];
224
+ if (!a.startsWith("--")) fail("bad_request", `unexpected argument "${a}" (session list takes flags only)`);
225
+ const name = a.slice(2);
226
+ if (BOOL_FLAGS.includes(name)) {
227
+ params.set(name.replaceAll("-", "_"), "1");
228
+ } else if (VALUE_FLAGS.includes(name)) {
229
+ const v = args[++i];
230
+ if (v === undefined) fail("bad_request", `--${name} needs a value`);
231
+ if (name === "limit" && !/^\d+$/.test(v)) fail("bad_request", `--limit wants a number, got "${v}"`);
232
+ if (name === "by" && v !== "workspace") fail("bad_request", `--by wants workspace, got "${v}"`);
233
+ params.set(name.replaceAll("-", "_"), v);
234
+ } else {
235
+ fail("bad_request", `unknown flag --${name} (see agentus capabilities)`);
236
+ }
237
+ }
238
+ const wantTable = params.get("table") === "1";
239
+ params.delete("table");
240
+ const qs = params.toString();
241
+ const data = await call(`/api/cockpit/context${qs ? `?${qs}` : ""}`);
242
+ if (wantTable) {
243
+ // Human output REPLACES the JSON document on stdout (documented in `capabilities`). Mixing the
244
+ // two would break both readers: a pipe wants one JSON document, an eye wants columns.
245
+ process.stdout.write(renderTable(data));
246
+ } else {
247
+ out({ success: true, data, meta: meta(data) });
248
+ }
249
+ } else if (verb === "rename") {
250
+ if (!id) fail("bad_request", "usage: agentus session rename <id> <new title…> | --auto");
251
+ const auto = args.includes("--auto");
252
+ const title = args.slice(1).filter((a) => a !== "--auto").join(" ").trim();
253
+ if (!auto && !title) fail("bad_request", "nothing to name it: pass the title after the id, or --auto to clear it");
254
+ if (auto && title) fail("bad_request", "--auto clears the name; do not also pass a title");
255
+ let res;
256
+ try {
257
+ res = await fetch(`${EP}/api/cockpit/rename`, {
258
+ method: "POST",
259
+ headers: headers(true),
260
+ body: JSON.stringify(auto ? { id, auto: true } : { id, title }),
261
+ });
262
+ } catch (e) {
263
+ fail("unreachable", `the cockpit did not answer: ${e?.message ?? e}`);
264
+ }
265
+ const body = await res.json().catch(() => null);
266
+ if (!res.ok) fail(String(body?.error ?? `http_${res.status}`), String(body?.error ?? `the cockpit answered HTTP ${res.status}`));
267
+ out({ success: true, data: body, meta: meta(body) });
268
+ } else if (verb === "show") {
269
+ if (!id) fail("bad_request", "usage: agentus session show <id>");
270
+ // Nothing here ignores a stray argument: `show a b` is a typo, and a CLI that silently drops half
271
+ // of its input is a CLI whose answers you cannot trust (AionUi's parser skips non-flag tokens;
272
+ // that is fine for its flag-only surface, wrong for one that takes ids).
273
+ if (args.length > 1) fail("bad_request", `unexpected argument "${args[1]}" (usage: agentus session show <id>)`);
274
+ const data = await call(`/api/cockpit/session?id=${q(id)}`);
275
+ out({ success: true, data, meta: meta(data) });
276
+ } else if (verb === "messages") {
277
+ if (!id) fail("bad_request", "usage: agentus session messages <id> [--tail N]");
278
+ const at = args.indexOf("--tail");
279
+ const tail = at >= 0 ? args[at + 1] : undefined;
280
+ if (at >= 0 && tail === undefined) fail("bad_request", "--tail needs a number");
281
+ const leftovers = args.filter((_, i) => i > 0 && i !== at && i !== at + 1);
282
+ if (leftovers.length) fail("bad_request", `unexpected argument "${leftovers[0]}" (usage: agentus session messages <id> [--tail N])`);
283
+ const data = await call(`/api/cockpit/messages?id=${q(id)}${tail ? `&tail=${q(tail)}` : ""}`);
284
+ out({ success: true, data, meta: meta(data) });
285
+ } else if (verb === "plan") {
286
+ if (!id) fail("bad_request", "usage: agentus session plan <id>");
287
+ if (args.length > 1) fail("bad_request", `unexpected argument "${args[1]}" (usage: agentus session plan <id>)`);
288
+ const data = await call(`/api/cockpit/plan?id=${q(id)}`);
289
+ out({ success: true, data, meta: meta(data) });
290
+ } else if (verb === "send") {
291
+ if (!id) fail("bad_request", "usage: agentus session send <id> <text…>");
292
+ // The text is the plain words after the id — an agent writing a sentence should not have to
293
+ // build JSON to say it. With no words, stdin is accepted: either a JSON `{"message": "…"}` (what
294
+ // older callers sent) or the raw text.
295
+ let message = args.slice(1).join(" ").trim();
296
+ if (!message) {
297
+ const raw = await readStdin();
298
+ if (raw) {
299
+ try {
300
+ const parsed = JSON.parse(raw);
301
+ message = String(parsed?.message ?? parsed?.text ?? "").trim();
302
+ } catch {
303
+ message = raw.trim();
304
+ }
305
+ }
306
+ }
307
+ if (!message) fail("bad_request", "nothing to send: pass the text after the id, or pipe it on stdin");
308
+ let res;
309
+ try {
310
+ res = await fetch(`${EP}/api/cockpit/send`, {
311
+ method: "POST",
312
+ headers: headers(true),
313
+ body: JSON.stringify({ to: id, message }),
314
+ });
315
+ } catch (e) {
316
+ fail("unreachable", `the cockpit did not answer: ${e?.message ?? e}`);
317
+ }
318
+ const body = await res.json().catch(() => null);
319
+ if (!res.ok) {
320
+ // The refusal meanings a model must not blur (see `capabilities.discipline`): none of these is
321
+ // "delivered", and target_busy specifically means the target saw NOTHING.
322
+ fail(String(body?.error ?? `http_${res.status}`), String(body?.error ?? `the cockpit answered HTTP ${res.status}`));
323
+ }
324
+ out({
325
+ success: true,
326
+ data: body,
327
+ meta: {
328
+ schema_version: 1,
329
+ note: "delivered means the target agent received it and started a turn — it does NOT mean it read or agreed. Only the operator sees the reply.",
330
+ },
331
+ });
332
+ } else {
333
+ process.stderr.write(USAGE);
334
+ fail("bad_request", `unknown session command: ${verb ?? "(none)"}`);
335
+ }
336
+ } else {
337
+ process.stderr.write(USAGE);
338
+ fail("bad_request", `unknown command: ${cmd}`);
339
+ }
@@ -34,8 +34,11 @@ brew install node # or https://nodejs.org/en/download
34
34
 
35
35
  Plain Hermes is enough to drive sessions. The capabilities this cockpit itself leans on are **not**
36
36
  in the released build — a per-session **thinking depth** (ACP `reasoning_effort`), a **permission
37
- mode that survives a restart** and `session/load`, and a free-form **context budget** (with
38
- 256K/512K/768K/1M presets). They live on the ACP branches of the fork
37
+ mode that survives a restart** and `session/load`, a free-form **context budget** (with
38
+ 256K/512K/768K/1M presets), the **status channel** that carries memory-recall / save hints into the
39
+ cockpit's message stream, and **per-process skill roots** (`HERMES_EXTERNAL_SKILLS_DIRS`, which is
40
+ how a slot sees the cockpit's own skill without anything being written into your `~/.hermes`).
41
+ They live on the ACP branches of the fork
39
42
  [`LiangYang666/hermes-agent`](https://github.com/LiangYang666/hermes-agent), and reach the cockpit
40
43
  through one **tag per integration build**, in a namespace of its own:
41
44
 
@@ -48,7 +51,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
48
51
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
49
52
  last segment sorts, so "newest" is a `sort -V`.
50
53
 
51
- **Current integration build: `agentus/v0.22.0/merge-20261015-0014`** — the newest tag on the fork,
54
+ **Current integration build: `agentus/v0.22.0/merge-20261017-2152`** — the newest tag on the fork,
52
55
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
53
56
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
54
57
  ever looks stale.
@@ -193,6 +196,7 @@ unless the backend row names another one — see [HERMES_HOME](#hermes_home).
193
196
  |---|---|---|
194
197
  | `AGENTUS_PORT` | `8788` | server port (never reads bare `PORT`) |
195
198
  | `AGENTUS_DATA` | `~/.agentus` | where state lives; see [Data directory](#data-directory) |
199
+ | `AGENTUS_TITLE` | `Agentus` | this instance's own name — the tab title and the PWA label, so several cockpits on one machine are told apart |
196
200
  | `AGENTUS_HERMES_CMD` | `hermes` | binary to spawn for the hermes backend |
197
201
  | `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | ditto for qoder |
198
202
  | `AGENTUS_HERMES_HOME` | `~/.hermes` | default `HERMES_HOME` for a row that does not name one |
@@ -31,8 +31,10 @@ brew install node # 或者 https://nodejs.org/en/download
31
31
  ### 该跑哪份 Hermes
32
32
 
33
33
  裸装的 Hermes 足够跑会话。但驾驶舱自己依赖的那几项能力**不在发布版里** —— 按会话切换的**思考深度**
34
- (ACP `reasoning_effort`)、**跨重启 / `session/load` 存活的权限模式**,以及可自由填写的**上下文预算**
35
- (带 256K/512K/768K/1M 预设)。它们在 fork
34
+ (ACP `reasoning_effort`)、**跨重启 / `session/load` 存活的权限模式**、可自由填写的**上下文预算**
35
+ (带 256K/512K/768K/1M 预设)、把记忆召回 / 存记忆提示送进驾驶舱消息流的 **status 通道**,以及
36
+ **按进程指定的技能根**(`HERMES_EXTERNAL_SKILLS_DIRS` —— 驾驶舱靠它让槽位看见自己的 skill,
37
+ 不往你的 `~/.hermes` 写任何东西)。它们在 fork
36
38
  [`LiangYang666/hermes-agent`](https://github.com/LiangYang666/hermes-agent) 的 ACP 分支上,
37
39
  按基座版本各有一个**集成构建的 tag**,自成命名空间:
38
40
 
@@ -44,7 +46,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
44
46
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
45
47
  可排序,所以「最新」就是一次 `sort -V`。
46
48
 
47
- **当前集成构建:`agentus/v0.22.0/merge-20261015-0014`** —— fork 上最新的那个 tag,基座 Hermes
49
+ **当前集成构建:`agentus/v0.22.0/merge-20261017-2152`** —— fork 上最新的那个 tag,基座 Hermes
48
50
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
49
51
  以它为准。
50
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.3.9",
3
+ "version": "0.4.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": {
@@ -15,6 +15,7 @@
15
15
  "agentus": "bin/agentus.mjs"
16
16
  },
17
17
  "files": [
18
+ "skills/",
18
19
  "bin/",
19
20
  "packages/server/src/",
20
21
  "packages/server/mock/",
@@ -52,6 +53,7 @@
52
53
  "workspace-smoke": "node scripts/workspace-smoke.mjs",
53
54
  "voice-smoke": "node scripts/voice-smoke.mjs",
54
55
  "sentence-units": "tsx scripts/qa/sentence-units.mts",
56
+ "barge-gate": "tsx scripts/qa/barge-gate.mts",
55
57
  "token-format": "tsx scripts/qa/token-format.mts",
56
58
  "terminal-palette": "tsx scripts/qa/terminal-palette.mts",
57
59
  "android-trust-merge": "node scripts/qa/android-trust-merge.mjs",
@@ -43,10 +43,19 @@ const TOOLS = [
43
43
  "multi-step task, and again each time a step's status changes — a plan that is not updated is a " +
44
44
  "plan that lies. `items` is the WHOLE list as it stands now (a snapshot, not a delta): include " +
45
45
  "every step, finished or not, in order. Statuses: pending, in_progress (at most one at a time), " +
46
- "completed, cancelled.",
46
+ "completed, cancelled. Give `title` on your first call for a multi-step task — a short action " +
47
+ "phrase (max 40 characters) naming the job, because that is the line the operator's completion " +
48
+ "notification shows on their phone. Without it the notification can only say the session's name.",
47
49
  inputSchema: {
48
50
  type: "object",
49
51
  properties: {
52
+ title: {
53
+ type: "string",
54
+ description:
55
+ "What this job IS, in one short action phrase (max 40 characters, e.g. 「修复登录超时」). " +
56
+ "Shown on the completion notification in place of the session name, so make it say the thing " +
57
+ "the operator would say. Written once; later calls without `title` keep the one stored.",
58
+ },
50
59
  items: {
51
60
  type: "array",
52
61
  description: "The full plan, in order. Replaces the previous list.",
@@ -104,7 +113,10 @@ function renderPlan(plan) {
104
113
  }
105
114
  const lines = plan.items.map((i) => `- [${i.status}] ${i.content}`);
106
115
  const head = `Plan (revision ${plan.revision ?? 0}${plan.source ? `, source ${plan.source}` : ""}${plan.terminal ? `, ${plan.terminal}` : ""}):`;
107
- return [head, ...lines, plan.explanation ? `\nRemark: ${plan.explanation}` : ""].filter(Boolean).join("\n");
116
+ return [
117
+ plan.title ? `Title: ${plan.title}` : "",
118
+ head, ...lines, plan.explanation ? `\nRemark: ${plan.explanation}` : "",
119
+ ].filter(Boolean).join("\n");
108
120
  }
109
121
 
110
122
  async function callTool(name, args) {
@@ -117,6 +129,9 @@ async function callTool(name, args) {
117
129
  if (!items) throw new Error("`items` must be an array of steps");
118
130
  const body = await callCockpit("update", {
119
131
  items,
132
+ // Both slots are optional and independent: absent = "keep what is stored", which is what makes a
133
+ // routine status flip unable to erase the title the completion card reads.
134
+ ...(args?.title !== undefined ? { title: args.title } : {}),
120
135
  ...(args?.explanation !== undefined ? { explanation: args.explanation } : {}),
121
136
  });
122
137
  const plan = body?.plan;
@@ -128,7 +143,7 @@ async function callTool(name, args) {
128
143
  if (body?.accepted === false) {
129
144
  return `${body.reason ?? "ignored: this session's plan comes from the agent's own frames"}. The plan on screen is unchanged (${done}/${total} done).`;
130
145
  }
131
- return `Plan saved (${done}/${total} done${plan?.explanation ? `; remark kept` : ""}). It survives your restarts.`;
146
+ return `Plan saved (${done}/${total} done${plan?.title ? `; title «${plan.title}»` : ""}${plan?.explanation ? `; remark kept` : ""}). It survives your restarts.`;
132
147
  }
133
148
  throw new Error(`unknown tool: ${name}`);
134
149
  }