agentus 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +476 -2
- package/README.zh-CN.md +396 -0
- package/bin/agentus.mjs +147 -0
- package/docs/android-notify-contract.md +658 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/docs/refs/aionui-acp-rules.md +46 -0
- package/docs/refs/hermes-studio-ui-ideas.md +191 -0
- package/docs/transcript-folding.md +139 -0
- package/package.json +76 -5
- package/packages/server/mcp/plan-server.mjs +217 -0
- package/packages/server/mock/agent.mjs +693 -0
- package/packages/server/package.json +22 -0
- package/packages/server/src/acp/backends.ts +196 -0
- package/packages/server/src/acp/errors.ts +46 -0
- package/packages/server/src/acp/registry.ts +579 -0
- package/packages/server/src/acp/session-manager.ts +1868 -0
- package/packages/server/src/auth.ts +688 -0
- package/packages/server/src/dashscope.ts +235 -0
- package/packages/server/src/fs.ts +379 -0
- package/packages/server/src/hotwords.ts +132 -0
- package/packages/server/src/index.ts +1486 -0
- package/packages/server/src/notify/center.ts +994 -0
- package/packages/server/src/notify/types.ts +222 -0
- package/packages/server/src/plan/plan.ts +126 -0
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/settings.ts +469 -0
- package/packages/server/src/store/export.ts +209 -0
- package/packages/server/src/store/store.ts +1238 -0
- package/packages/server/src/term.ts +201 -0
- package/packages/server/src/title.ts +109 -0
- package/packages/server/src/voice.ts +251 -0
- package/packages/shared/package.json +14 -0
- package/packages/shared/src/index.ts +271 -0
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
- package/packages/web/dist/coding-agents/hermes.png +0 -0
- package/packages/web/dist/coding-agents/qoder.svg +5 -0
- package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-icon.svg +301 -0
- package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-mark.svg +301 -0
- package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/packages/web/dist/icons/favicon.ico +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/icon-maskable-512.png +0 -0
- package/packages/web/dist/index.html +30 -0
- package/packages/web/dist/manifest.webmanifest +17 -0
- package/packages/web/dist/sw.js +61 -0
- package/scripts/make-cert.sh +101 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# The transcript: one message, one row, one bubble
|
|
2
|
+
|
|
3
|
+
Why this file exists: the operator looked at the `hello` session and found **one reply rendered as five
|
|
4
|
+
messages with a markdown table cut in half**, and "show earlier messages" producing a few unrelated
|
|
5
|
+
fragments. Both were real, and both came from the same three assumptions being wrong at once. This is
|
|
6
|
+
what they were, what was done about them, and how it is verified.
|
|
7
|
+
|
|
8
|
+
## What was actually wrong
|
|
9
|
+
|
|
10
|
+
Measured on the operator's own session (`hello`, 8 prompts):
|
|
11
|
+
|
|
12
|
+
| Fact | Number |
|
|
13
|
+
|---|---|
|
|
14
|
+
| rows in `messages` for that one session | **3804** |
|
|
15
|
+
| rows carrying a `messageId` (real streamed chunks) | 3702, **average 3.8 characters per row** |
|
|
16
|
+
| rows with **no** `messageId` (whole messages the agent re-sent) | 57, average 341 characters |
|
|
17
|
+
| rows for the reply the operator was reading | **1259** |
|
|
18
|
+
| client page size when opening a session / on "load earlier" | 500 rows → ~1900 chars, then 200 rows each |
|
|
19
|
+
|
|
20
|
+
So three things were true:
|
|
21
|
+
|
|
22
|
+
1. **A row was a token, and paging was by row.** Opening the session fetched the newest 500 rows — a
|
|
23
|
+
third of ONE reply; each "load earlier" added another 200 rows, i.e. another arbitrary byte slice.
|
|
24
|
+
The client built bubbles from *adjacent same-kind rows*, so every page boundary became a message
|
|
25
|
+
boundary. Measured: the table sat at rows 2875–3142 and the page boundary was row **3105** — the
|
|
26
|
+
table was split exactly there, and nothing in either half was valid markdown any more.
|
|
27
|
+
2. **The client merged by adjacency, not by identity**, so the same logical message could not be
|
|
28
|
+
recognised across a page boundary (or across a reconnect replay).
|
|
29
|
+
3. **The agent re-sends text it has already sent** (a recap of a message, sometimes with different
|
|
30
|
+
whitespace, sometimes as one anonymous block). Persisted naively, the same answer landed in the
|
|
31
|
+
transcript up to six times, each copy its own bubble.
|
|
32
|
+
|
|
33
|
+
## What the two sibling projects do
|
|
34
|
+
|
|
35
|
+
- **hermes-studio** (`packages/server/src/services/agent-runner/coding-agent-run-manager.ts`,
|
|
36
|
+
`run-chat/response-stream.ts`): the streaming accumulator is `appendedTextDelta(existing, next)` — if
|
|
37
|
+
the incoming frame starts with what we hold it is a snapshot (take the tail), otherwise strip the
|
|
38
|
+
longest ≥16-character overlap, otherwise it is a genuine delta. That is the re-send problem solved on
|
|
39
|
+
the server, which is where the row is written.
|
|
40
|
+
- **AionUi** (`packages/desktop/src/renderer/pages/conversation/Messages/hooks.ts`): each message has a
|
|
41
|
+
stable key (`type:msg_id`, namespaced so a thought block and a text block sharing one id cannot
|
|
42
|
+
collide), a `msgIdIndex` map so a frame finds **its own** entry instead of "the last one", a
|
|
43
|
+
`replace` flag for snapshot content, and `mergeLoadedPageWithCurrent` / `prependHistoryMessages`
|
|
44
|
+
dedupe a page against what is on screen **by that key** — so a message straddling a page boundary
|
|
45
|
+
stays one entry. Their comment is explicit that a tool/thinking interruption is allowed to keep the
|
|
46
|
+
message boundary.
|
|
47
|
+
|
|
48
|
+
## What Agentus does now
|
|
49
|
+
|
|
50
|
+
**1. The store writes one row per message** (`Store.appendTextChunk`, `store/store.ts`):
|
|
51
|
+
|
|
52
|
+
- chunks are folded into the row that already holds this `messageId` (`messages.block_key`, indexed);
|
|
53
|
+
- `appendedTextDelta` decides what is new, so a snapshot adds only its tail and an exact re-send adds
|
|
54
|
+
nothing;
|
|
55
|
+
- an **id-less** frame is folded into a recent same-kind row when `reemissionTarget` says it is the
|
|
56
|
+
same block: the normalized text must be equal, or contained with the shorter covering ≥60% of the
|
|
57
|
+
longer (either direction, so "the same block, extended" appends). Below 24 characters it is never
|
|
58
|
+
folded — "好的" twice is two messages, and a short string is contained in almost every long block;
|
|
59
|
+
- the surviving row keeps its original `seq` (paging anchors stay valid, nothing is renumbered).
|
|
60
|
+
|
|
61
|
+
**2. Paging is bounded by rows AND bytes** (`messagesBefore`): 120 rows, 256 KB, always at least one row.
|
|
62
|
+
A page can no longer be "a third of a message", and one 30 KB message cannot overflow a phone fetch.
|
|
63
|
+
|
|
64
|
+
**3. The client folds by key** (`packages/web/src/transcript.ts`, used by `state.ts`):
|
|
65
|
+
|
|
66
|
+
- a live frame carries `delta` (wire addition on `{ t: "message" }`) and appends to the block with that
|
|
67
|
+
key — even if a tool card arrived in between, because one `messageId` is one message;
|
|
68
|
+
- a **read** (history, replay) carries no `delta` and *replaces* that block's text with the row's, so
|
|
69
|
+
re-reading can never duplicate;
|
|
70
|
+
- `loadEarlier` folds the older page into a scratch list first and then merges **by key** into what is on
|
|
71
|
+
screen (longer text wins), then re-indexes;
|
|
72
|
+
- a frame with no key falls back to the old adjacent-merge, which is correct for the agent's anonymous
|
|
73
|
+
recaps.
|
|
74
|
+
|
|
75
|
+
## The rule that was missing: a growing row's version is its LENGTH, not its seq
|
|
76
|
+
|
|
77
|
+
The first cut of all this shipped a bug, and it is worth writing down because the same trap is one line
|
|
78
|
+
away in any streaming UI: **folding a message into one row means that row keeps its seq.** The client
|
|
79
|
+
had a guard from before — "skip a frame whose seq I have already seen" — which was correct when every
|
|
80
|
+
chunk was its own row, and silently wrong the moment chunks started folding. Every chunk after the
|
|
81
|
+
first was dropped. On the phone the reply read `不是` (literally the first chunk) and the next frame
|
|
82
|
+
landed in an empty bubble (its first chunk had been whitespace).
|
|
83
|
+
|
|
84
|
+
The fix has two halves, and both are needed:
|
|
85
|
+
|
|
86
|
+
- the server sends `n`, the block's **total length after this frame**, and on a growth ships only the new
|
|
87
|
+
part (so a long reply is not re-sent whole on every token);
|
|
88
|
+
- the client guards a growing frame by that length, never by `seq`: `missing = n - held.length`, and if
|
|
89
|
+
it is `≤ 0` the frame is a duplicate, otherwise it appends the missing tail (`part.slice(-missing)`,
|
|
90
|
+
which also covers a frame whose head we already hold).
|
|
91
|
+
|
|
92
|
+
A read (no `delta`) needs none of this: the stored row IS the block, so it either replaces the text or
|
|
93
|
+
is a no-op when it matches.
|
|
94
|
+
|
|
95
|
+
`npm run live-stream` is the guard for it: it sends a real prompt through the mock backend, feeds every
|
|
96
|
+
frame the server emits into the **real cockpit store** (`apply`, the same entry point the WebSocket
|
|
97
|
+
calls), and asserts that the transcript built from the live frames equals the rows the store persisted —
|
|
98
|
+
same bubble count, exactly the same text, no empty bubble. Reverting the guard to `seq`-only makes that
|
|
99
|
+
check fail on the text comparison, which is how we know the test has teeth rather than just passing.
|
|
100
|
+
|
|
101
|
+
## Where the re-send problem is *not* solved
|
|
102
|
+
|
|
103
|
+
A short id-less message that the agent re-sends (measured: a 12-character "不客气,随时找我干活 👋" three
|
|
104
|
+
times) still appears more than once. Folding it needs information beyond the text — a two-character
|
|
105
|
+
"好的" repeated is a legitimate second message — so the guard stays at 24 characters and the leftover is
|
|
106
|
+
accepted rather than guessed away.
|
|
107
|
+
|
|
108
|
+
## Invariants to keep
|
|
109
|
+
|
|
110
|
+
1. **One `messageId` = one row = one bubble.** If a new event kind streams text, it must go through
|
|
111
|
+
`appendTextChunk` with its id, or it re-opens the whole problem.
|
|
112
|
+
2. **The row is the truth on reads; `delta` exists only so a live turn does not re-send the whole text
|
|
113
|
+
every frame.** Anything that reads the transcript (`hotwords.ts` context, the phone's notifications)
|
|
114
|
+
reads rows, so it sees accumulated text for free — it used to see 40 chunks of 3.8 characters.
|
|
115
|
+
3. **Never fold on a guess.** Same id, or whitespace-insensitive equality, or ≥60% coverage. Everything
|
|
116
|
+
else stays as it is, and the migration is logged (`[store] 历史分片已折叠:3804 行 → …`) with a
|
|
117
|
+
one-time `VACUUM INTO` backup beside the DB (`agentus.sqlite.pre-fold.bak`).
|
|
118
|
+
4. **A page boundary must not be able to cut a message.** Page sizes are row- and byte-bounded, and the
|
|
119
|
+
store guarantees a message is one row; if either changes, re-run the checks below.
|
|
120
|
+
|
|
121
|
+
## Verify it
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm run transcript-fold # 17 checks: folding rules on their own (page boundary, snapshot, no-id)
|
|
125
|
+
npm run store-fold # 27 checks: accumulation, re-sends, byte budget, the legacy migration
|
|
126
|
+
npm run smoke # a real (mock) turn through the live server: 2 rows, not 17
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
And against real data — the one-off used when this landed, on the operator's own session:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# rows → the CLIENT's fold, over the operator API
|
|
133
|
+
curl -s -H "Authorization: Bearer $(cat "${AGENTUS_DATA:-$HOME/.agentus}/auth.token")" \
|
|
134
|
+
'http://127.0.0.1:8787/api/sessions/<id>/messages?tail=1'
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Result for `hello`: **3804 rows → 70** (10 agent messages, 15 thought blocks, 37 tool cards, 8 prompts),
|
|
138
|
+
`hasOlder=false`, both markdown tables reported complete (the 2623-character reply holds its 8-row table
|
|
139
|
+
in one block), and one duplicate left — the 12-character re-send described above.
|
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "agentus",
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/agentus-org/agentus.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/agentus-org/agentus#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/agentus-org/agentus/issues"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"agentus": "bin/agentus.mjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin/",
|
|
19
|
+
"packages/server/src/",
|
|
20
|
+
"packages/server/mock/",
|
|
21
|
+
"packages/server/mcp/",
|
|
22
|
+
"packages/server/package.json",
|
|
23
|
+
"packages/shared/src/",
|
|
24
|
+
"packages/shared/package.json",
|
|
25
|
+
"packages/web/dist/",
|
|
26
|
+
"scripts/make-cert.sh",
|
|
27
|
+
"docs/",
|
|
28
|
+
"NOTICE"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@agentclientprotocol/sdk": "1.5.1",
|
|
32
|
+
"tsx": "^4.19.0",
|
|
33
|
+
"ws": "^8.22.0"
|
|
34
|
+
},
|
|
35
|
+
"workspaces": [
|
|
36
|
+
"packages/*"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "npm run dev -w @agentus/server & npm run dev -w @agentus/web & wait",
|
|
40
|
+
"dev:server": "npm run dev -w @agentus/server",
|
|
41
|
+
"dev:web": "npm run dev -w @agentus/web",
|
|
42
|
+
"build": "npm run build -ws --if-present",
|
|
43
|
+
"typecheck": "npm run typecheck -ws --if-present",
|
|
44
|
+
"lint": "npm run lint -ws --if-present",
|
|
45
|
+
"test": "npm run test -ws --if-present",
|
|
46
|
+
"start": "bash scripts/start.sh",
|
|
47
|
+
"agentus": "node bin/agentus.mjs",
|
|
48
|
+
"prepack": "npm run build -w @agentus/web",
|
|
49
|
+
"package-smoke": "node scripts/qa/package-install.mjs",
|
|
50
|
+
"smoke": "node scripts/smoke.mjs",
|
|
51
|
+
"backend-smoke": "node scripts/backend-registry-smoke.mjs",
|
|
52
|
+
"auth-smoke": "node scripts/auth-smoke.mjs",
|
|
53
|
+
"workspace-smoke": "node scripts/workspace-smoke.mjs",
|
|
54
|
+
"voice-smoke": "node scripts/voice-smoke.mjs",
|
|
55
|
+
"sentence-units": "tsx scripts/qa/sentence-units.mts",
|
|
56
|
+
"plan-service-sweep": "node scripts/qa/plan-service-sweep.mjs",
|
|
57
|
+
"plan-mcp-sweep": "node scripts/qa/plan-mcp-sweep.mjs",
|
|
58
|
+
"plan-v2-sweep": "tsx scripts/qa/plan-v2-sweep.mts",
|
|
59
|
+
"tls-smoke": "node scripts/tls-smoke.mjs",
|
|
60
|
+
"notify-testbed": "tsx scripts/notify-testbed.mts",
|
|
61
|
+
"notify-smoke": "tsx scripts/notify-smoke.mts",
|
|
62
|
+
"apk": "bash scripts/build-apk.sh",
|
|
63
|
+
"transcript-fold": "tsx scripts/qa/transcript-fold.mts",
|
|
64
|
+
"store-fold": "tsx scripts/qa/store-fold.mts",
|
|
65
|
+
"live-stream": "tsx scripts/qa/live-stream.mts",
|
|
66
|
+
"open-target": "tsx scripts/qa/open-target.mts",
|
|
67
|
+
"rail-recent": "tsx scripts/qa/rail-recent.mts",
|
|
68
|
+
"turn-pulse": "tsx scripts/qa/turn-pulse.mts",
|
|
69
|
+
"resume-tail": "tsx scripts/qa/resume-tail.mts",
|
|
70
|
+
"render-lag-sweep": "node scripts/qa/render-lag-sweep.mjs",
|
|
71
|
+
"replay-window": "node scripts/qa/replay-window.mjs",
|
|
72
|
+
"think-cadence": "node scripts/qa/think-cadence.mjs"
|
|
73
|
+
},
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": ">=22.5"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `agentus-plan` — the cockpit's plan tool, as a stdio MCP server.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* The cockpit owns a session's plan (design-plan-service.md §2-3) and an agent's own todo list
|
|
7
|
+
* dies with its process. So the plan the card renders is the cockpit's OBJECT, and this tool is
|
|
8
|
+
* how the agent keeps it current: every backend is handed it at handshake time (a row can still
|
|
9
|
+
* opt back into rendering its own ACP frames — `nativePlanSource: "acp"`). An agent needs **zero
|
|
10
|
+
* adapter code**: it just has to speak MCP.
|
|
11
|
+
*
|
|
12
|
+
* HOW IT IS REACHED (and why that is safe)
|
|
13
|
+
* Nothing registers this server in an agent's own config. The cockpit only ever hands it over as
|
|
14
|
+
* part of the ACP handshake (`session/new` / `session/load` / `session/resume` → `mcpServers`),
|
|
15
|
+
* which is a per-session parameter, so an agent started any other way (CLI, cron, Studio) does
|
|
16
|
+
* not have it — see design-plan-service.md §4. The same handshake carries the three variables
|
|
17
|
+
* below, including a token minted for THAT ONE session: even someone who copies the command out
|
|
18
|
+
* of `ps` can only write the plan of the session the token was minted for.
|
|
19
|
+
*
|
|
20
|
+
* PROTOCOL NOTES
|
|
21
|
+
* Transport is newline-delimited JSON-RPC 2.0 (what the MCP stdio transport mandates). stdout
|
|
22
|
+
* carries protocol frames ONLY — anything printed there that is not a JSON-RPC message breaks the
|
|
23
|
+
* client, so all diagnostics go to stderr.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ENDPOINT = (process.env.AGENTUS_PLAN_ENDPOINT ?? "").replace(/\/+$/, "");
|
|
27
|
+
const TOKEN = process.env.AGENTUS_PLAN_TOKEN ?? "";
|
|
28
|
+
const SESSION = process.env.AGENTUS_PLAN_SESSION ?? "";
|
|
29
|
+
|
|
30
|
+
/** MCP revision this server implements. Cheap to keep current: we only use tools/*. */
|
|
31
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
32
|
+
|
|
33
|
+
const STATUSES = ["pending", "in_progress", "completed", "cancelled"];
|
|
34
|
+
|
|
35
|
+
const TOOLS = [
|
|
36
|
+
{
|
|
37
|
+
name: "update_plan",
|
|
38
|
+
description:
|
|
39
|
+
"Publish this session's plan — the step list the operator watches in the Agentus cockpit, one " +
|
|
40
|
+
"progress dot per step. PREFER THIS over any checklist/todo tool of your own: this list is the " +
|
|
41
|
+
"one the cockpit DISPLAYS, and it survives your restarts, while a built-in task list dies with " +
|
|
42
|
+
"your process (the cockpit does not read it and will not show it). Call it when you START a " +
|
|
43
|
+
"multi-step task, and again each time a step's status changes — a plan that is not updated is a " +
|
|
44
|
+
"plan that lies. `items` is the WHOLE list as it stands now (a snapshot, not a delta): include " +
|
|
45
|
+
"every step, finished or not, in order. Statuses: pending, in_progress (at most one at a time), " +
|
|
46
|
+
"completed, cancelled.",
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
items: {
|
|
51
|
+
type: "array",
|
|
52
|
+
description: "The full plan, in order. Replaces the previous list.",
|
|
53
|
+
items: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
content: { type: "string", description: "What the step is, in the operator's language." },
|
|
57
|
+
status: { type: "string", enum: STATUSES, description: "Where this step stands now." },
|
|
58
|
+
priority: { type: "string", enum: ["high", "medium", "low"], description: "Optional." },
|
|
59
|
+
},
|
|
60
|
+
required: ["content", "status"],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
explanation: {
|
|
64
|
+
type: "string",
|
|
65
|
+
description:
|
|
66
|
+
"Optional one-line remark shown under the list: why the plan changed, or how far the " +
|
|
67
|
+
"scope reaches. Max 1000 characters.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required: ["items"],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "read_plan",
|
|
75
|
+
description:
|
|
76
|
+
"Read the plan the cockpit currently holds for this session. Use it after a restart, or when " +
|
|
77
|
+
"the operator asks what is still outstanding, instead of trusting your own memory of it.",
|
|
78
|
+
inputSchema: { type: "object", properties: {} },
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** POST one operation to the cockpit, which owns the plan object. */
|
|
83
|
+
async function callCockpit(op, payload) {
|
|
84
|
+
if (!ENDPOINT || !TOKEN) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"this plan tool was started without AGENTUS_PLAN_ENDPOINT/AGENTUS_PLAN_TOKEN — it only works " +
|
|
87
|
+
"inside an Agentus session; keep your own todo list instead",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const res = await fetch(`${ENDPOINT}/api/plan/mcp`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "content-type": "application/json", "x-agentus-plan-token": TOKEN },
|
|
93
|
+
body: JSON.stringify({ session: SESSION, op, ...payload }),
|
|
94
|
+
});
|
|
95
|
+
const body = await res.json().catch(() => null);
|
|
96
|
+
if (!res.ok) throw new Error(body?.error ?? `the cockpit answered HTTP ${res.status}`);
|
|
97
|
+
return body;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Render the plan for the agent: compact, and honest about what is not there. */
|
|
101
|
+
function renderPlan(plan) {
|
|
102
|
+
if (!plan || !Array.isArray(plan.items) || plan.items.length === 0) {
|
|
103
|
+
return "This session has no plan yet. Publish one with update_plan when the work has steps.";
|
|
104
|
+
}
|
|
105
|
+
const lines = plan.items.map((i) => `- [${i.status}] ${i.content}`);
|
|
106
|
+
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");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function callTool(name, args) {
|
|
111
|
+
if (name === "read_plan") {
|
|
112
|
+
const body = await callCockpit("read", {});
|
|
113
|
+
return renderPlan(body?.plan);
|
|
114
|
+
}
|
|
115
|
+
if (name === "update_plan") {
|
|
116
|
+
const items = Array.isArray(args?.items) ? args.items : null;
|
|
117
|
+
if (!items) throw new Error("`items` must be an array of steps");
|
|
118
|
+
const body = await callCockpit("update", {
|
|
119
|
+
items,
|
|
120
|
+
...(args?.explanation !== undefined ? { explanation: args.explanation } : {}),
|
|
121
|
+
});
|
|
122
|
+
const plan = body?.plan;
|
|
123
|
+
const done = plan?.items?.filter((i) => i.status === "completed").length ?? 0;
|
|
124
|
+
const total = plan?.items?.length ?? 0;
|
|
125
|
+
// Say something the model can act on, including the case where the cockpit IGNORED the write:
|
|
126
|
+
// a session already fed by native ACP frames has one writer, and this tool is not it. Silently
|
|
127
|
+
// succeeding there would leave the model believing it owns a list it does not.
|
|
128
|
+
if (body?.accepted === false) {
|
|
129
|
+
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
|
+
}
|
|
131
|
+
return `Plan saved (${done}/${total} done${plan?.explanation ? `; remark kept` : ""}). It survives your restarts.`;
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`unknown tool: ${name}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- the stdio loop -----------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function send(msg) {
|
|
139
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function reply(id, result) {
|
|
143
|
+
send({ jsonrpc: "2.0", id, result });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function replyError(id, code, message) {
|
|
147
|
+
send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function handle(msg) {
|
|
151
|
+
const { id, method, params } = msg;
|
|
152
|
+
// Notifications carry no id and must never be answered.
|
|
153
|
+
const isNotification = id === undefined || id === null;
|
|
154
|
+
switch (method) {
|
|
155
|
+
case "initialize":
|
|
156
|
+
reply(id, {
|
|
157
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
158
|
+
capabilities: { tools: {} },
|
|
159
|
+
serverInfo: { name: "agentus-plan", version: "1.0.0" },
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
case "notifications/initialized":
|
|
163
|
+
case "notifications/cancelled":
|
|
164
|
+
return;
|
|
165
|
+
case "ping":
|
|
166
|
+
reply(id, {});
|
|
167
|
+
return;
|
|
168
|
+
case "tools/list":
|
|
169
|
+
reply(id, { tools: TOOLS });
|
|
170
|
+
return;
|
|
171
|
+
case "tools/call": {
|
|
172
|
+
const name = String(params?.name ?? "");
|
|
173
|
+
try {
|
|
174
|
+
const text = await callTool(name, params?.arguments ?? {});
|
|
175
|
+
reply(id, { content: [{ type: "text", text }] });
|
|
176
|
+
} catch (e) {
|
|
177
|
+
// isError (not a JSON-RPC error): the model should see the failure as tool output and
|
|
178
|
+
// react — retrying a transport failure as if it were a protocol error helps nobody.
|
|
179
|
+
reply(id, {
|
|
180
|
+
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
|
181
|
+
isError: true,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
default:
|
|
187
|
+
if (!isNotification) replyError(id, -32601, `method not found: ${String(method)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let buffer = "";
|
|
192
|
+
process.stdin.setEncoding("utf8");
|
|
193
|
+
process.stdin.on("data", (chunk) => {
|
|
194
|
+
buffer += chunk;
|
|
195
|
+
for (;;) {
|
|
196
|
+
const nl = buffer.indexOf("\n");
|
|
197
|
+
if (nl < 0) break;
|
|
198
|
+
const line = buffer.slice(0, nl).trim();
|
|
199
|
+
buffer = buffer.slice(nl + 1);
|
|
200
|
+
if (!line) continue;
|
|
201
|
+
let msg;
|
|
202
|
+
try {
|
|
203
|
+
msg = JSON.parse(line);
|
|
204
|
+
} catch {
|
|
205
|
+
process.stderr.write(`agentus-plan: ignoring a line that is not JSON\n`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
handle(msg).catch((e) => {
|
|
209
|
+
process.stderr.write(`agentus-plan: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
210
|
+
if (msg?.id !== undefined && msg?.id !== null) replyError(msg.id, -32603, "internal error");
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
process.stdin.on("end", () => process.exit(0));
|
|
215
|
+
process.stderr.write(
|
|
216
|
+
`agentus-plan ready (session ${SESSION || "?"}, endpoint ${ENDPOINT || "unset"})\n`,
|
|
217
|
+
);
|