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 +39 -3
- package/README.zh-CN.md +34 -2
- package/bin/agentus.mjs +27 -0
- package/bin/cli.mjs +339 -0
- package/docs/installation.md +7 -3
- package/docs/installation.zh-CN.md +5 -3
- 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-CMRMZCT8.js +0 -122
- package/packages/web/dist/assets/index-LqLBh4zI.css +0 -32
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
|
|
53
|
-
|
|
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-
|
|
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
|
-
|
|
47
|
+
**上下文预算**输入框、把记忆召回 / 存记忆提示送进消息流的 **status 通道** —— 来自 fork 的集成构建(tag)。
|
|
48
|
+
装法就五行:
|
|
48
49
|
[§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
|
|
49
|
-
`agentus/v0.22.0/merge-
|
|
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
|
+
}
|
package/docs/installation.md
CHANGED
|
@@ -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`,
|
|
38
|
-
256K/512K/768K/1M presets)
|
|
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-
|
|
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
|
|
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-
|
|
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
|
+
"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 [
|
|
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
|
}
|