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 +35 -0
- package/README.zh-CN.md +31 -0
- package/bin/agentus.mjs +27 -0
- package/bin/cli.mjs +339 -0
- package/docs/installation.md +1 -0
- package/package.json +3 -1
- package/packages/server/mcp/plan-server.mjs +18 -3
- package/packages/server/mock/agent.mjs +19 -0
- package/packages/server/src/acp/backends.ts +25 -0
- package/packages/server/src/acp/registry.ts +6 -0
- package/packages/server/src/acp/session-manager.ts +269 -25
- package/packages/server/src/cockpit/api.ts +402 -0
- package/packages/server/src/index.ts +293 -7
- package/packages/server/src/notify/center.ts +55 -4
- package/packages/server/src/notify/types.ts +6 -1
- package/packages/server/src/observer/hub.ts +327 -0
- package/packages/server/src/plan/plan.ts +16 -0
- package/packages/server/src/settings.ts +166 -8
- package/packages/server/src/skills/install.ts +102 -0
- package/packages/server/src/store/store.ts +51 -13
- package/packages/shared/src/index.ts +34 -1
- package/packages/web/dist/assets/index-CRo7LVdP.css +32 -0
- package/packages/web/dist/assets/index-q4g0lPdX.js +123 -0
- package/packages/web/dist/index.html +2 -2
- package/skills/agentus-cockpit/SKILL.md +183 -0
- package/packages/web/dist/assets/index-DgPLH0bG.css +0 -32
- package/packages/web/dist/assets/index-x3AVKD8Y.js +0 -122
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
|
+
}
|
package/docs/installation.md
CHANGED
|
@@ -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
|
+
"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 [
|
|
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 = {
|