agentus 0.3.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,6 +110,7 @@ layer never changes.
110
110
  |---|---|---|
111
111
  | `AGENTUS_PORT` | `8788` | server port (never reads bare `PORT` — that name is polluted on shared hosts) |
112
112
  | `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) |
113
+ | `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
114
  | `AGENTUS_HERMES_CMD` | `hermes` | binary to spawn for the hermes backend |
114
115
  | `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | ditto for qoder |
115
116
  | `AGENTUS_HERMES_HOME` | `~/.hermes` | default `HERMES_HOME` for a row that does not name one (the operator's own home) |
@@ -277,6 +278,40 @@ A row that points at its own home needs no permission — only a name. One trap
277
278
  a row points elsewhere: a fresh home whose `hindsight/config.json` keeps the default profile
278
279
  name `"hermes"` would attach to your production memory daemon, so the row warns when it would.
279
280
 
281
+ ## The cockpit CLI (for the agents inside it)
282
+
283
+ An agent in a slot is blind: the other sessions, what they are doing, and what the operator
284
+ asked them all live in the cockpit, not in the agent's prompt. `agentus cockpit` is the one
285
+ channel that lets an agent read that — and, if you allow it, hand a message to another session:
286
+
287
+ ```bash
288
+ "$AGENTUS_COCKPIT_BIN" capabilities # the contract, self-describing (start here)
289
+ "$AGENTUS_COCKPIT_BIN" session list --here # …narrowed: my workspace, newest activity first
290
+ "$AGENTUS_COCKPIT_BIN" session list --waiting --table # …the operator's approval queue, as a table
291
+ "$AGENTUS_COCKPIT_BIN" session rename <id> "…name…" # name a session (the row records who did it)
292
+ "$AGENTUS_COCKPIT_BIN" session show <id> # one session, + what it is waiting on
293
+ "$AGENTUS_COCKPIT_BIN" session messages <id> --tail 20
294
+ "$AGENTUS_COCKPIT_BIN" session plan <id>
295
+ "$AGENTUS_COCKPIT_BIN" session send <id> "…a message…" # operator-gated, off by default
296
+ "$AGENTUS_COCKPIT_BIN" skill # this CLI's own manual, from the package
297
+ ```
298
+
299
+ The shape is noun-then-verb (`agentus session send <id> …`) because every one of these acts on the
300
+ same object; the session id is a positional argument, options stay options.
301
+
302
+ The cockpit hands `AGENTUS_COCKPIT_BIN/_ENDPOINT/_TOKEN/_SESSION` to each agent it spawns, so
303
+ the CLI needs no arguments, no port and no config — and the token never appears on a command
304
+ line. `AGENTUS_COCKPIT_BIN` is this same `agentus` launcher, so there is one command with one
305
+ name; `agentus cockpit …` at your own shell is the identical thing. Delivering a message (**设置 → 驾驶舱互发消息**, off by default) means the target agent
306
+ *received* it, never that it read or agreed; a busy target refuses with `target_busy` and saw
307
+ nothing.
308
+
309
+ The manual is `skills/agentus-cockpit/SKILL.md`, shipped in the package. Backends with a native
310
+ skills directory (Hermes: `$HERMES_HOME/skills`) get it installed at spawn — idempotently, by
311
+ content, so a release that changes it refreshes it and a spawn that would not costs nothing.
312
+ Backends without one (Qoder) get a single line in the plan reminder and can read the same file
313
+ with `agentus cockpit skill`, which is also why the document is never pasted into a prompt.
314
+
280
315
  ## What it does today (M0 → M4)
281
316
 
282
317
  - multi-session rail, **grouped by working directory** — running sessions and **closed
package/README.zh-CN.md CHANGED
@@ -240,6 +240,37 @@ Hermes 内置的 SQLite 3.50.4(落在 WAL-reset 区间内)在两个写入者
240
240
  `hindsight/config.json` 保持默认 profile 名 `"hermes"` 的话,会接上你的生产记忆守护进程,所以这种
241
241
  情况下那一行会给出警告。
242
242
 
243
+ ## 驾驶舱 CLI(给里面的 agent 用)
244
+
245
+ 槽位里的 agent 是瞎子:别的会话在干什么、你让它改什么,都在驾驶舱里,不在它的 prompt 里。
246
+ `agentus cockpit` 是唯一一条让 agent 读到这些的通道 —— 以及(你允许时)把一句话传给另一个会话:
247
+
248
+ ```bash
249
+ "$AGENTUS_COCKPIT_BIN" capabilities # 契约本身,自述(先跑这个)
250
+ "$AGENTUS_COCKPIT_BIN" session list --here # 收窄:我的工作空间,按活跃时间倒序
251
+ "$AGENTUS_COCKPIT_BIN" session list --waiting --table # 操作者的待审批队列,打成表
252
+ "$AGENTUS_COCKPIT_BIN" session rename <id> "…名字…" # 给会话改名(行里会记下是谁改的)
253
+ "$AGENTUS_COCKPIT_BIN" session show <id> # 单个会话,外加它此刻在等什么
254
+ "$AGENTUS_COCKPIT_BIN" session messages <id> --tail 20
255
+ "$AGENTUS_COCKPIT_BIN" session plan <id>
256
+ "$AGENTUS_COCKPIT_BIN" session send <id> "…一句话…" # 需要操作者开启,默认关闭
257
+ "$AGENTUS_COCKPIT_BIN" skill # 这份 CLI 的说明书,直接从包里取
258
+ ```
259
+
260
+ 形状是「名词 + 动词」(`agentus session send <id> …`),因为这些动作都作用在同一个对象上;
261
+ 会话 id 是位置参数,选项才用 `--`。
262
+
263
+ 身份由驾驶舱在 spawn 时注入(`AGENTUS_COCKPIT_BIN/_ENDPOINT/_TOKEN/_SESSION`),所以 CLI
264
+ 不要参数、不要端口、不要配置,token 也不会出现在命令行上。`AGENTUS_COCKPIT_BIN` 就是同一个
265
+ `agentus` 启动器 —— 命令只有一个、名字也只有一个,你在自己 shell 里敲 `agentus cockpit …` 是同一件事。互发消息(**设置 → 驾驶舱互发消息**,
266
+ 默认关闭)里的「已送达」只意味着对方 agent **收到**并开始一轮,绝不等于它读懂了或同意了;
267
+ 对方正在跑回合时会拒绝并回 `target_busy`,那条消息哪里都没进。
268
+
269
+ 说明书就是 `skills/agentus-cockpit/SKILL.md`,随包发布。有原生技能目录的后端(Hermes:
270
+ `$HERMES_HOME/skills`)在 spawn 时装进去 —— 按内容幂等,所以新版本会刷新、重复 spawn 不产生写盘;
271
+ 没有原生目录的后端(Qoder)只在计划提示里拿一行,需要时用 `agentus cockpit skill` 读同一份文件,
272
+ 这也正是它永远不被塞进 prompt 的原因。
273
+
243
274
  ## 目前能做什么(M0 → M4)
244
275
 
245
276
  - 多会话侧栏,**按工作目录分组** —— 运行中的会话和**已关闭的案例**(进程已退出的 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
+ }
@@ -193,6 +193,7 @@ unless the backend row names another one — see [HERMES_HOME](#hermes_home).
193
193
  |---|---|---|
194
194
  | `AGENTUS_PORT` | `8788` | server port (never reads bare `PORT`) |
195
195
  | `AGENTUS_DATA` | `~/.agentus` | where state lives; see [Data directory](#data-directory) |
196
+ | `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
197
  | `AGENTUS_HERMES_CMD` | `hermes` | binary to spawn for the hermes backend |
197
198
  | `AGENTUS_QODER_CMD` | `~/.local/bin/qodercli` | ditto for qoder |
198
199
  | `AGENTUS_HERMES_HOME` | `~/.hermes` | default `HERMES_HOME` for a row that does not name one |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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
  }
@@ -179,6 +179,11 @@ const agent = () => ({
179
179
  // feature is such a client — it forks the session, asks the fork to summarise, and
180
180
  // throws the fork away. Advertising it makes that path testable offline.
181
181
  sessionCapabilities: { list: {}, cancel: {}, fork: {} },
182
+ // Issue #17: ACP lets an agent say which prompt parts it takes, and this one starts out
183
+ // saying NO to images — the honest default, because a non-vision model is the common case
184
+ // and a silently dropped image is what the cockpit's gate exists to prevent. `MOCK_IMAGE=1`
185
+ // flips it, so both sides of the gate are testable without a vision model.
186
+ promptCapabilities: { image: process.env.MOCK_IMAGE === "1", audio: false, embeddedContext: false },
182
187
  },
183
188
  authMethods: [{ id: "mock", name: "Mock auth (no-op)" }],
184
189
  };
@@ -411,6 +416,20 @@ const agent = () => ({
411
416
  // per-turn slow mode: long stream so QA can drop the socket mid-turn (AC6)
412
417
  const slow = /\[slow\]/.test(text) ? Math.max(SLOW, 900) : SLOW;
413
418
 
419
+ // `[status]` — the agent-status carrier from issue #18, exactly as hermes' `make_status_cb` sends
420
+ // it: an EMPTY thought chunk whose `_meta.hermes` holds the sentence. Sent mid-turn on purpose, so a
421
+ // sweep can prove the line appears while the turn runs and is gone when it ends, and that NOTHING
422
+ // of it reaches the stored transcript.
423
+ if (/\[status\]/.test(text)) {
424
+ setTimeout(() => {
425
+ void send(agent._conn, sessionId, {
426
+ sessionUpdate: "agent_thought_chunk",
427
+ _meta: { hermes: { kind: "status", statusKind: "lifecycle", text: "👁️ Hindsight — recalled 8 memories" } },
428
+ content: { type: "text", text: "" },
429
+ }).catch(() => undefined);
430
+ }, Math.min(slow * 2, 1200));
431
+ }
432
+
414
433
  // `[table]` — a table fixture for the sorting / full-screen sweep (scripts/qa/table-sweep.mjs).
415
434
  // Numeric AND text cells, deliberately NOT in any sorted order, and a local file link plus a
416
435
  // relative image, so "clicking a header reorders the rows" and "a relative path resolves" are
@@ -54,6 +54,25 @@ export interface BackendSpec {
54
54
  env?: Record<string, string>;
55
55
  /** hermes profile, composed into argv by registry.effectiveArgs (`hermes -p <profile> acp`) */
56
56
  profile?: string | null;
57
+ /**
58
+ * Where this agent looks for skills, if it has such a place. `{home}` is the home we hand the child.
59
+ *
60
+ * ACP defines no skill object, so discovery is per-vendor (AionCore keys it off a per-agent
61
+ * `native_skills_dirs`; see docs/refs/aionui-acp-rules.md). A row that names a dir gets this
62
+ * package's skills installed there at spawn; a row that does not gets the one-line prompt hint and
63
+ * can still read the same document on demand (`agentus cockpit skill`). Absent ≠ broken.
64
+ */
65
+ skillsDir?: string;
66
+ /**
67
+ * The env var this vendor reads as "extra skill roots for THIS process", when it has one.
68
+ *
69
+ * This is the first choice and `skillsDir` is the fallback: it scopes our skills to the one agent
70
+ * process we spawn, so the operator's own `~/.hermes` (shared with Studio, the CLI and every other
71
+ * Hermes on the box) never learns that Agentus exists. Hermes names it
72
+ * `HERMES_EXTERNAL_SKILLS_DIRS`, the per-process half of `skills.external_dirs` — see the upstream
73
+ * PR draft in the workspace (`areas/upstream-prs/`), because stock Hermes has no such variable yet.
74
+ */
75
+ skillsEnv?: string;
57
76
  /** free-form note shown next to the row in the UI */
58
77
  notes?: string;
59
78
  }
@@ -78,6 +97,12 @@ export const BACKENDS: Record<string, BackendSpec> = {
78
97
  homeVar: "HERMES_HOME",
79
98
  homeDefault: defaultHermesHome,
80
99
  },
100
+ // `hermes skill_view` scans <HERMES_HOME>/skills/<name>/SKILL.md at session start — writing there
101
+ // would land in the operator's REAL home, which Studio and every terminal `hermes` also read. So
102
+ // we hand the child one more root for its own lifetime instead (no copy, no stale twin, nothing
103
+ // outside Agentus sees it). `skillsDir` stays as the documented fallback for vendors that only
104
+ // know how to scan their home.
105
+ skillsEnv: "HERMES_EXTERNAL_SKILLS_DIRS",
81
106
  },
82
107
  qoder: {
83
108
  label: "Qoder",
@@ -271,6 +271,12 @@ export function rowToSpec(row: BackendRow): BackendSpec {
271
271
  needsLoginHint: row.kind === "qoder" ? "qodercli login" : undefined,
272
272
  env: { ...(row.env ?? {}) },
273
273
  profile: row.profile,
274
+ // `skillsDir` is a property of the AGENT KIND, not of the operator's row: where a vendor looks
275
+ // for skills is not something a settings page should have to know. Taken from the builtin of the
276
+ // same kind, so a stored row inherits it and a new hermes row needs no migration (this is the bug
277
+ // that made the first install silently skip every real row — stored rows never reach BACKENDS).
278
+ skillsDir: BACKENDS[row.kind as keyof typeof BACKENDS]?.skillsDir,
279
+ skillsEnv: BACKENDS[row.kind as keyof typeof BACKENDS]?.skillsEnv,
274
280
  };
275
281
  if (row.kind === "hermes") {
276
282
  spec.isolation = {