@rien7/agent-center-host 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +335 -2
- package/dist/agent-tokens.js +105 -0
- package/dist/center-tools.js +882 -0
- package/dist/cli.js +242 -0
- package/dist/config.js +86 -0
- package/dist/core-client.js +151 -0
- package/dist/credentials.js +117 -0
- package/dist/crew-tools.js +115 -0
- package/dist/driver.js +87 -0
- package/dist/host.js +435 -0
- package/dist/index.js +10 -0
- package/dist/login.js +103 -0
- package/dist/memory.js +163 -0
- package/dist/oar-driver.js +1331 -0
- package/dist/prompt.js +82 -0
- package/dist/tool-titles.js +157 -0
- package/dist/wire.js +6 -0
- package/package.json +46 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RieN7
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,336 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agent-host
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The process that runs agents for Agent Center. It connects out to Center with its own
|
|
4
|
+
credential, claims queued turns, answers them through a **driver** and streams the reply
|
|
5
|
+
back as `AgentChatTurnEvent`s.
|
|
6
|
+
|
|
7
|
+
- **OarDriver** runs a real agent through [oar](https://github.com/botiverse/oar)
|
|
8
|
+
(`@botiverse/oar`, pinned to an exact version): **Claude Code** by default, **pi**
|
|
9
|
+
(bundled, in-process, any OpenAI/Anthropic-compatible provider) per agent.
|
|
10
|
+
- **EchoDriver** plays a fixed script (`runtime: "echo"`), for testing the wire.
|
|
11
|
+
- **Target: Node 24.** Published to npm as
|
|
12
|
+
[`@rien7/agent-center-host`](https://www.npmjs.com/package/@rien7/agent-center-host): compiled ESM
|
|
13
|
+
in `dist/` (`tsc -p tsconfig.build.json`), Node >= 24 (oar's own engine). In this repo
|
|
14
|
+
the sources are plain TypeScript that Bun and Node 24 run directly (type stripping, so
|
|
15
|
+
only erasable syntax and `.ts` imports; the build rewrites them to `.js`).
|
|
16
|
+
- Server side: `domains/agent` (ops `agent.host.register`, `host.heartbeat`,
|
|
17
|
+
`turn.claim`, `turn.open`, `event.append`, `turn.finish`, `agent.upsert`,
|
|
18
|
+
`agent.issue_token`, `memory_file.*`; SSE `GET /v1/agent/events?hostId=`).
|
|
19
|
+
|
|
20
|
+
## On a Mac (three steps)
|
|
21
|
+
|
|
22
|
+
You need Node 24 or later, and Claude Code installed and logged in (`claude`, then
|
|
23
|
+
`/login`; check with `claude auth status`).
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
# 1. Log in: prints a pairing code; approve it in the Agent Center app.
|
|
27
|
+
npx @rien7/agent-center-host login --server https://agent-center.rien7.com
|
|
28
|
+
|
|
29
|
+
# 2. Check: Center login, Claude Code found and logged in, workdir writable.
|
|
30
|
+
npx @rien7/agent-center-host doctor
|
|
31
|
+
|
|
32
|
+
# 3. Run (keep it open; Ctrl-C stops it, the agents show offline in the app).
|
|
33
|
+
npx @rien7/agent-center-host run --name mac
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or install it once (`npm i -g @rien7/agent-center-host`) and run `agent-center-host …` (also
|
|
37
|
+
installed as `agent-host`). From a checkout of this repo (after `bun install`), use
|
|
38
|
+
`bun packages/agent-host/src/cli.ts …` instead.
|
|
39
|
+
|
|
40
|
+
That's all: without a `host.json` agent list the host runs one agent, 「助手」, on Claude
|
|
41
|
+
Code, and gets its Center token from Center by itself (read everything, write memos,
|
|
42
|
+
reminders and calendar). The agent appears in the app's agent menu with a green dot while
|
|
43
|
+
the host runs. Its memory is in `~/.agent-center/work/agents/<id>/memory/`.
|
|
44
|
+
|
|
45
|
+
What `doctor` prints when all is well:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
✓ login: https://agent-center.example as agent-host mac (agent:host)
|
|
49
|
+
✓ runtime claude: /opt/homebrew/bin/claude 2.1.x
|
|
50
|
+
✓ claude login: you@example.com, max, …
|
|
51
|
+
✓ workdir: /Users/you/.agent-center/work
|
|
52
|
+
· agent 助手: claude, token issued by Center (/Users/you/.agent-center/agent-tokens.json)
|
|
53
|
+
· sub-agents: on
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
When Claude Code isn't found, install it (`npm i -g @anthropic-ai/claude-code`) or point
|
|
57
|
+
`OAR_CLAUDE_BIN` at it; when it is logged out, run `claude` and `/login`. Logs go to
|
|
58
|
+
stderr: each turn (`answering`, `finished`), each session (`opened claude session …
|
|
59
|
+
(records …)`), the token (`助手: got its own Center token`).
|
|
60
|
+
|
|
61
|
+
## Use
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
# 1. Log in (RFC 8628 device flow, scope agent:host). Approve the request in the app
|
|
65
|
+
# (or via core.oauth_request.approve) with the pairing code it prints.
|
|
66
|
+
node packages/agent-host/bin/agent-host.js login --server https://agent-center.example
|
|
67
|
+
# → ~/.agent-center/host.json (0600; AGENT_CENTER_HOST_CONFIG overrides the path)
|
|
68
|
+
|
|
69
|
+
# 2. Optional: agents in host.json, next to the login (see "host.json" below). Without
|
|
70
|
+
# any the host runs one agent, 助手. Each agent's token comes from Center
|
|
71
|
+
# (agent.issue_token) unless host.json gives one made by hand:
|
|
72
|
+
# bun run cli principal:create --name 助手 --kind agent \
|
|
73
|
+
# --scopes '*:read,agent:read,memo:write,reminder:write,calendar:write'
|
|
74
|
+
|
|
75
|
+
# 3. Run. Agents in host.json are matched by id or name, created when missing.
|
|
76
|
+
node packages/agent-host/bin/agent-host.js run --name mac-studio
|
|
77
|
+
# --runtime echo: the echo driver (creates an agent 回声 when there are no agents)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
With Bun, replace `node` with `bun`; from npm, use `npx @rien7/agent-center-host` instead. Instead of logging in you can pass a static token,
|
|
81
|
+
for example a service principal from `bun run cli principal:create --name mac-studio
|
|
82
|
+
--kind service --scopes agent:host`:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
AGENT_CENTER_TOKEN=ac_… node packages/agent-host/bin/agent-host.js run --name mac-studio --server http://127.0.0.1:8800
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then send a message as in `domains/agent/README.md` (`agent.turn.start`) or from the app.
|
|
89
|
+
With the echo driver the reply is a thought, one `echo` tool call, your text streamed
|
|
90
|
+
back as 「你说:…」, and a record card pointing at your own message.
|
|
91
|
+
|
|
92
|
+
## host.json
|
|
93
|
+
|
|
94
|
+
```jsonc
|
|
95
|
+
{
|
|
96
|
+
"server": "https://…", "accessToken": "ac_…", // the host's login (agent-host login)
|
|
97
|
+
"runtime": "claude", // default runtime: claude | pi | … | echo
|
|
98
|
+
"model": "sonnet", // default model; pi: "provider/model" from models.json
|
|
99
|
+
"workdir": "~/.agent-center/work",
|
|
100
|
+
"timeZone": "Asia/Shanghai", // 「今天」 and the [时间] line; default: the machine's
|
|
101
|
+
"idleMinutes": 15, // close an idle agent's oar session (resumed later)
|
|
102
|
+
"contextTools": "inline", // default; or "direct", see Tools
|
|
103
|
+
"piAgentDir": "~/.agent-center/pi", // pi's models.json / auth.json (OAR_PI_AGENT_DIR)
|
|
104
|
+
"subagents": true, // main agents get agent_spawn / send / wait / close
|
|
105
|
+
"agents": [ // default: [{ "name": "助手" }]
|
|
106
|
+
{ "name": "助手", "role": "…", "runtime": "pi", "model": "airrouter/…",
|
|
107
|
+
"scopes": ["memo:write", "reminder:write"], // write scopes of its issued token
|
|
108
|
+
"token": "ac_…" } // optional, made by hand; or "tokenEnv": "AGENT_TOKEN_ASSISTANT"
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
On start the host checks every agent token with `core.principal.me` and refuses one
|
|
114
|
+
that carries `*`, `agent:host`, `agent:*`, `core:*` or `core:admin`.
|
|
115
|
+
|
|
116
|
+
**Models.** Claude Code uses its own login (`claude auth login`, or
|
|
117
|
+
`CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`). pi needs a provider: an env key
|
|
118
|
+
(`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, …) or a `models.json` provider in `piAgentDir`,
|
|
119
|
+
e.g. an OpenAI-compatible gateway:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{"providers":{"airrouter":{"baseUrl":"https://…/v1","api":"openai-completions","apiKey":"$AIRROUTER_KEY","models":[{"id":"…"}]}}}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
For development without any key, `test/fixtures/mock-openai.ts` is a scripted
|
|
126
|
+
OpenAI-compatible server (`bun packages/agent-host/test/fixtures/mock-openai.ts 18999`
|
|
127
|
+
prints the `models.json` to use; model `mock/mock-1`).
|
|
128
|
+
|
|
129
|
+
## Tools
|
|
130
|
+
|
|
131
|
+
Core's `/mcp` has ~100 tools (~130 KB of schemas), too many to put in front of a model on
|
|
132
|
+
every request. The host serves its own small MCP server per agent instead
|
|
133
|
+
(oar `serveMcpTools`: loopback HTTP with a random bearer, attached to the session; chosen
|
|
134
|
+
over stdio because the handlers run in the host, which holds the agent token, and both
|
|
135
|
+
pi and Claude Code take HTTP MCP servers). The model sees:
|
|
136
|
+
|
|
137
|
+
| tool | what |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `tools_search({query?, domain?})` | tool names + one line each; `domain` lists a whole domain, `query` searches (中文 or English) |
|
|
140
|
+
| `tool_describe({name})` | one tool's description and input schema |
|
|
141
|
+
| `tool_call({name, input})` | run it: proxied to core `/mcp` with the agent's token |
|
|
142
|
+
| `today_overview({date?})` | a day's events, reminders (plus overdue ones today) and visits |
|
|
143
|
+
| `agent_search_query` | full-text search over past conversations (core's own tool) |
|
|
144
|
+
|
|
145
|
+
plus the runtime's file tools for memory (pi `read/edit/write`; Claude Code
|
|
146
|
+
`Read/Edit/Write/…`; shell, web and sub-agent tools are disallowed).
|
|
147
|
+
|
|
148
|
+
- **Domain catalog.** The system prompt lists every domain the token can read, one line
|
|
149
|
+
each, built from `core.domain.list` and the tool list: `reminder 提醒:待办和提醒 —
|
|
150
|
+
item_list, item_create, …` (~400 tokens; long domains like ledger shrink to a count and
|
|
151
|
+
their common tools).
|
|
152
|
+
- **The context's domains.** When a message carries a `context` (the 今天 page →
|
|
153
|
+
reminder, calendar, location; a record → its domain), each of those domains' list/get
|
|
154
|
+
and its one common write (`reminder_item_create`, `calendar_event_create`,
|
|
155
|
+
`memo_note_create`) are expanded once per session; the domain's other tools are named
|
|
156
|
+
and stay a `tool_describe` away. By default (`contextTools: "inline"`) their parameters
|
|
157
|
+
are inlined in that turn's prefix (今天: ~1.7k tokens, once). With `"direct"` they're
|
|
158
|
+
added as MCP tools with full schemas instead (~2.8k tokens on every request; pi takes
|
|
159
|
+
the tool-list change fully, Claude Code takes additions).
|
|
160
|
+
- **Denylist.** Scopes are the real boundary; the host also keeps out sync/credential
|
|
161
|
+
plumbing (`core_sync_*`, `core_change_*`, `core_command_*`, …), `*_settings_update` and
|
|
162
|
+
every `agent_*` op but search and reads.
|
|
163
|
+
- **Titles.** Steps are titled by the inner tool in Chinese (`tool-titles.ts`):
|
|
164
|
+
`tool_call(reminder_item_create)` shows as 「新建提醒」 with the input's title as detail.
|
|
165
|
+
- **Record cards.** A write's result carries its `commandId`; the host reads
|
|
166
|
+
`core.command.get` + `core.entity.get` (agent token) and appends a `record` card per
|
|
167
|
+
changed entity (at most 3).
|
|
168
|
+
|
|
169
|
+
## Memory and records
|
|
170
|
+
|
|
171
|
+
- `<workdir>/agents/<agentId>/` is the session's cwd. `memory/MEMORY.md` (index) and
|
|
172
|
+
`memory/notes/*.md` are mirrored with Center (`agent.memory_file.*`): pulled at start
|
|
173
|
+
(Center wins), pushed after each turn and on file changes with `ifVersion` (a conflict
|
|
174
|
+
takes Center's copy). A new agent gets a template MEMORY.md. MEMORY.md (≤8k characters)
|
|
175
|
+
is put in front of a turn's message whenever it changed since the session last saw it.
|
|
176
|
+
- Every turn's message starts with `[时间] <local time>` and, with a context,
|
|
177
|
+
`[上下文] 用户正在看「今天」页(…)`.
|
|
178
|
+
- oar's raw records go to `<workdir>/records/<agentId>/<sessionId>-<opened>.jsonl`.
|
|
179
|
+
- The oar session id is stored as the agent's `resumeSessionId`; after a restart or an
|
|
180
|
+
idle close the session is resumed (a fresh one when that fails). A turn that was
|
|
181
|
+
half-answered when the host died is run again once: the model gets
|
|
182
|
+
「上一轮被打断,请继续。…」 and the user's message, the reply goes on after what it already
|
|
183
|
+
had (「(host 重启了,接着说)」). A second restart in the same turn fails it (turn ids
|
|
184
|
+
run again are kept in `<workdir>/resumed-turns.json`).
|
|
185
|
+
- Core runs one turn per agent at a time, so a second message waits for the first (the
|
|
186
|
+
driver also handles oar's steer/queue landings for when that changes).
|
|
187
|
+
- **Inactive history.** When the session compacts (oar `compaction_ended`, completed),
|
|
188
|
+
the host calls `agent.history.mark_inactive` with `beforeTurnId` (the tool calls of every
|
|
189
|
+
earlier turn of the agent) and the calls the current turn made so far; the messages stay
|
|
190
|
+
(the summary keeps them). When oar drops an accepted input (`input_dropped`; oar 0.86
|
|
191
|
+
has no other rollback record), that turn fails and its user message, reply and tool calls
|
|
192
|
+
are marked inactive. The mark waits for the turn's queued events to reach Center.
|
|
193
|
+
`search.query` and `message.list` leave inactive records out unless `includeInactive`.
|
|
194
|
+
|
|
195
|
+
`Ctrl-C` flushes queued events, marks the host's agents offline and leaves any running
|
|
196
|
+
turn running. The next `run` resumes it.
|
|
197
|
+
|
|
198
|
+
## Sub-agents
|
|
199
|
+
|
|
200
|
+
A main agent can hand a self-contained job to a sub-agent (`subagents: true`, the
|
|
201
|
+
default). Four host-served tools, on top of oar's crew (`@botiverse/oar/agents`):
|
|
202
|
+
|
|
203
|
+
| tool | what |
|
|
204
|
+
|---|---|
|
|
205
|
+
| `agent_spawn({name, task, writeScopes?})` 「开子 agent」 | a new core agent: `parentAgentId` = the parent, this host, the parent's runtime and model; its own token (read-only, plus `writeScopes`, at most the parent's own write scopes); its own workdir and memory. The task is a `turn.open` turn in the child's new conversation (context 「「助手」交给的任务」), the child's session is opened by the crew with it. The parent's reply gets a card 「子 agent「…」开始了」 into that conversation. |
|
|
206
|
+
| `agent_send({agent, message})` 「给子 agent 发消息」 | a follow-up as another `turn.open` turn in the child's conversation; refused while the child is busy |
|
|
207
|
+
| `agent_wait({agents?, timeoutSeconds?})` 「等子 agent 的报告」 | the reports that came in (waits up to 120 s by default); each gets a card |
|
|
208
|
+
| `agent_close({agent})` 「结束子 agent」 | closes its session; the agent and its conversation stay (status offline); a later message reopens it |
|
|
209
|
+
|
|
210
|
+
- **Reports.** Every turn the parent started ends in a report (the child's reply, which
|
|
211
|
+
is already in the child's conversation). It goes to a waiting `agent_wait`, else to
|
|
212
|
+
the parent as a `turn.open` turn in the conversation the task came from: the stored
|
|
213
|
+
message is 「子 agent「…」的报告:…」 (context: the child's conversation; oar origin
|
|
214
|
+
`notification`), the reply starts with a card 「子 agent「…」完成了」 whose uri is
|
|
215
|
+
`agent:conversation/<child conversation>`; the app opens that conversation. While the
|
|
216
|
+
parent is busy, reports wait for its turn to end.
|
|
217
|
+
- **Talking to a child.** It is in `agent.list` like any agent; the user's turns are
|
|
218
|
+
claimed and answered by the same host. Those turns don't report to the parent.
|
|
219
|
+
- **Limits.** Children don't get the four tools (depth 2). At most 4 children run at
|
|
220
|
+
once, 8 open per parent. After a restart the host takes its children back
|
|
221
|
+
(`parentAgentId` one of its agents) with their saved tokens and resumes their sessions.
|
|
222
|
+
|
|
223
|
+
## Presence
|
|
224
|
+
|
|
225
|
+
The host says `host.heartbeat` every 20 s. Center marks a host's agents offline when its
|
|
226
|
+
event stream closes (5 s grace) or after 60 s without any call, and online again on the
|
|
227
|
+
next one; `Ctrl-C` marks them offline at once. The app shows the state in the agent menu
|
|
228
|
+
and, for an offline agent, 「…现在离线,消息会排队,等它上线后回复」: turns stay queued and
|
|
229
|
+
are answered when the host is back.
|
|
230
|
+
|
|
231
|
+
## Tokens
|
|
232
|
+
|
|
233
|
+
The host token carries `agent:host`. It can claim turns and write replies for every agent
|
|
234
|
+
assigned to the host (and for unassigned ones), but it can't read the owner's data.
|
|
235
|
+
**Per-agent tokens, the credentials an agent's tools use to call Center, must never
|
|
236
|
+
carry `agent:host`.** An agent holding it could claim other agents' turns and write their
|
|
237
|
+
replies. `agent:host` is an explicit scope: `agent:write`, `*:write` or `agent:*` don't
|
|
238
|
+
grant it. Never hand the host's own token to a runtime or its tools either; it stays in
|
|
239
|
+
the host process.
|
|
240
|
+
|
|
241
|
+
**Issued agent tokens.** An agent without a token in host.json gets one from Center: the
|
|
242
|
+
host calls `agent.agent.issue_token({agentId, scopes})` with its own credential. Center
|
|
243
|
+
allows it only for agents assigned to the calling host, gives `*:read agent:read` plus
|
|
244
|
+
the write scopes asked for, and refuses `*`, `agent:host`, anything implying
|
|
245
|
+
`agent:write` and `core:*` beyond `core:read`. The agent keeps one principal (kind
|
|
246
|
+
agent, `agent.tokenPrincipalId`); calling again replaces its scopes and revokes its
|
|
247
|
+
earlier tokens. The host keeps the tokens in `agent-tokens.json` next to host.json
|
|
248
|
+
(0600, per server) and asks for a new one when Center rejects the saved one or the
|
|
249
|
+
scopes changed. Why a host op rather than giving the host `core:token`/`core:admin`:
|
|
250
|
+
those reach every principal, while this op can only mint restricted tokens for the
|
|
251
|
+
host's own agents, so a stolen host token still can't read the owner's data or act as
|
|
252
|
+
the owner.
|
|
253
|
+
|
|
254
|
+
## Server
|
|
255
|
+
|
|
256
|
+
`deploy/agent-host.Dockerfile` (Node 24 + oar + Claude Code) and the opt-in
|
|
257
|
+
`deploy/compose.agent-host.yaml` (next to `api`, MCP via `http://api:8800`, a volume for
|
|
258
|
+
`/data`).
|
|
259
|
+
|
|
260
|
+
## Shape
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
src/core-client.ts CoreClient interface + HttpCoreClient (ops, SSE parsing, 401 → refresh)
|
|
264
|
+
src/driver.ts Driver interface (start, run(turn, sink, signal), stop) + EchoDriver
|
|
265
|
+
src/oar-driver.ts OarDriver: oar sessions per agent, event mapping, cards, resume, idle
|
|
266
|
+
src/center-tools.ts the agent's Center tools (catalog, search, describe, call, overview)
|
|
267
|
+
src/tool-titles.ts Chinese step titles
|
|
268
|
+
src/prompt.ts system prompt + per-turn prefix
|
|
269
|
+
src/memory.ts memory files ↔ Center
|
|
270
|
+
src/host.ts AgentHost: register, listen, claim, batch, stop, reconnect, resume
|
|
271
|
+
src/config.ts host.json settings (runtime, model, workdir, agents)
|
|
272
|
+
src/credentials.ts host.json login, TokenSource (OAuth refresh)
|
|
273
|
+
src/login.ts device login
|
|
274
|
+
src/crew-tools.ts agent_spawn / agent_send / agent_wait / agent_close (sub-agents, in oar-driver.ts)
|
|
275
|
+
src/agent-tokens.ts the agents' issued tokens (agent-tokens.json)
|
|
276
|
+
src/cli.ts agent-host login | run | doctor
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- **Claiming.** The host claims (`resume: true`, only its own agents) on start, on every
|
|
280
|
+
stream `ready`, on each `turn` push with state `queued`, after each finished turn (an
|
|
281
|
+
agent's next queued turn isn't pushed again), and on a 30 s safety poll.
|
|
282
|
+
- **Batching.** Events are appended every 100 ms, and immediately on `done`/`failed`, one
|
|
283
|
+
request at a time. Each batch's idempotency key is `ev-<turnId>-<seq>`, so a retry
|
|
284
|
+
after a network error is applied once.
|
|
285
|
+
- **Stop.** A `turn` push with state `stopped` (or a 409/403 on append) aborts the
|
|
286
|
+
runtime and drops its queued events.
|
|
287
|
+
- **Reconnect.** When the stream ends, errors, or goes 45 s without even a heartbeat, the
|
|
288
|
+
host reconnects with jittered exponential backoff (0.5 s up to 30 s) and re-claims.
|
|
289
|
+
- **Resume.** A running turn claimed back after a restart carries `eventCount`. The
|
|
290
|
+
driver gets it as `appended`: EchoDriver is deterministic and skips that many events;
|
|
291
|
+
OarDriver can't replay and runs the turn again once (see Memory and records).
|
|
292
|
+
- **Host-opened turns.** A driver may add agents (`addAgent`) and open turns
|
|
293
|
+
(`openTurn` → `turn.open`, already running and claimed by this host); they run
|
|
294
|
+
through the same batching as claimed ones.
|
|
295
|
+
|
|
296
|
+
## Test
|
|
297
|
+
|
|
298
|
+
```sh
|
|
299
|
+
bun test packages/agent-host # core in-process over real HTTP + the host loop;
|
|
300
|
+
# oar-driver.test.ts: pi + the mock model, end to end
|
|
301
|
+
# subagents.test.ts: issued tokens, memory notes,
|
|
302
|
+
# sub-agents, presence, interrupted turns
|
|
303
|
+
# inactive.test.ts: compaction / dropped input →
|
|
304
|
+
# inactive history (fake oar session)
|
|
305
|
+
bun x tsc -p packages/agent-host # typecheck against Node types
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Publishing
|
|
309
|
+
|
|
310
|
+
Releases go out from GitHub Actions (`.github/workflows/agent-host-publish.yml`) with npm
|
|
311
|
+
trusted publishing (OIDC, no npm token):
|
|
312
|
+
|
|
313
|
+
1. Bump `version` in `packages/agent-host/package.json`, commit and merge to `main`.
|
|
314
|
+
2. Tag that commit and push the tag:
|
|
315
|
+
|
|
316
|
+
```sh
|
|
317
|
+
git tag agent-host-v0.1.1 && git push origin agent-host-v0.1.1
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The workflow checks that the tag is on `main` and matches the package version, runs the
|
|
321
|
+
tests and typecheck, builds, smoke-tests the packed tarball and runs `npm publish
|
|
322
|
+
--access public`.
|
|
323
|
+
|
|
324
|
+
One-time setup (the package has to exist on npm before a trusted publisher can be
|
|
325
|
+
added):
|
|
326
|
+
|
|
327
|
+
```sh
|
|
328
|
+
cd packages/agent-host
|
|
329
|
+
npm login
|
|
330
|
+
npm publish --access public # prepublishOnly builds dist/ first
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
then on npmjs.com → @rien7/agent-center-host → Settings → Trusted publishing → GitHub Actions:
|
|
334
|
+
owner `rien7`, repository `agent-center`, workflow `agent-host-publish.yml`, no
|
|
335
|
+
environment, allowed action **npm publish**. The first CI publish must happen within
|
|
336
|
+
2 days of adding it. `npm pack --dry-run` shows what ships: `dist/` and this README.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agents' own Center tokens, issued by Center to the host (`agent.agent.issue_token`)
|
|
3
|
+
* and kept next to the host's login in `agent-tokens.json` (0600, per server). An agent
|
|
4
|
+
* token is restricted: read-only by default plus the write scopes asked for, never
|
|
5
|
+
* `agent:host`, `agent:write` or core beyond read (Center refuses those).
|
|
6
|
+
*
|
|
7
|
+
* A token written by hand in host.json (`agents[].token` / `tokenEnv`) still wins.
|
|
8
|
+
*/
|
|
9
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { configPath } from "./credentials.js";
|
|
12
|
+
/** What a new agent may write by default (on top of *:read, agent:read). */
|
|
13
|
+
export const defaultAgentScopes = [
|
|
14
|
+
"memo:write",
|
|
15
|
+
"reminder:write",
|
|
16
|
+
"calendar:write",
|
|
17
|
+
];
|
|
18
|
+
export const agentTokensPath = (env = process.env) => env.AGENT_CENTER_AGENT_TOKENS ??
|
|
19
|
+
join(dirname(configPath(env)), "agent-tokens.json");
|
|
20
|
+
export class AgentTokens {
|
|
21
|
+
path;
|
|
22
|
+
server;
|
|
23
|
+
client;
|
|
24
|
+
file = null;
|
|
25
|
+
chain = Promise.resolve();
|
|
26
|
+
/** `path: null` keeps the tokens in memory only (tests, read-only setups). */
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.client = options.client;
|
|
29
|
+
this.server = options.server;
|
|
30
|
+
this.path = options.path === undefined ? agentTokensPath() : options.path;
|
|
31
|
+
}
|
|
32
|
+
async load() {
|
|
33
|
+
if (this.file)
|
|
34
|
+
return this.file;
|
|
35
|
+
let file = {};
|
|
36
|
+
if (this.path)
|
|
37
|
+
try {
|
|
38
|
+
file = JSON.parse(await readFile(this.path, "utf8"));
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== "ENOENT")
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
this.file = file;
|
|
45
|
+
return file;
|
|
46
|
+
}
|
|
47
|
+
save() {
|
|
48
|
+
const path = this.path;
|
|
49
|
+
const file = this.file;
|
|
50
|
+
if (!path || !file)
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
this.chain = this.chain.then(async () => {
|
|
53
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
54
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
55
|
+
await writeFile(tmp, `${JSON.stringify(file, null, 2)}\n`, {
|
|
56
|
+
mode: 0o600,
|
|
57
|
+
});
|
|
58
|
+
await chmod(tmp, 0o600);
|
|
59
|
+
await rename(tmp, path);
|
|
60
|
+
});
|
|
61
|
+
return this.chain;
|
|
62
|
+
}
|
|
63
|
+
/** What is saved for an agent (its token and the scopes asked for), if anything. */
|
|
64
|
+
async entry(agentId) {
|
|
65
|
+
return (await this.load())[this.server]?.[agentId] ?? null;
|
|
66
|
+
}
|
|
67
|
+
/** The saved token of an agent, if it has one with these scopes. */
|
|
68
|
+
async saved(agentId, scopes) {
|
|
69
|
+
const entry = (await this.load())[this.server]?.[agentId];
|
|
70
|
+
return entry && sameScopes(entry.scopes, scopes) ? entry.token : null;
|
|
71
|
+
}
|
|
72
|
+
/** Ask Center for a (new) token and keep it; earlier tokens of the agent are revoked. */
|
|
73
|
+
async issue(agentId, scopes) {
|
|
74
|
+
if (!this.client.issueToken)
|
|
75
|
+
throw new Error("this Center client cannot issue agent tokens");
|
|
76
|
+
const issued = await this.client.issueToken(agentId, scopes);
|
|
77
|
+
const file = await this.load();
|
|
78
|
+
file[this.server] ??= {};
|
|
79
|
+
file[this.server][agentId] = {
|
|
80
|
+
token: issued.token,
|
|
81
|
+
scopes,
|
|
82
|
+
principalId: issued.principalId,
|
|
83
|
+
};
|
|
84
|
+
await this.save();
|
|
85
|
+
return issued.token;
|
|
86
|
+
}
|
|
87
|
+
async forget(agentId) {
|
|
88
|
+
const file = await this.load();
|
|
89
|
+
if (file[this.server]?.[agentId]) {
|
|
90
|
+
delete file[this.server][agentId];
|
|
91
|
+
await this.save();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A working token for the agent: the saved one when `check` accepts it, else a newly
|
|
96
|
+
* issued one. `check` returns false when Center rejects the token (401).
|
|
97
|
+
*/
|
|
98
|
+
async get(agentId, scopes, check) {
|
|
99
|
+
const saved = await this.saved(agentId, scopes);
|
|
100
|
+
if (saved && (await check(saved)))
|
|
101
|
+
return { token: saved, issued: false };
|
|
102
|
+
return { token: await this.issue(agentId, scopes), issued: true };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const sameScopes = (a, b) => a.length === b.length && [...a].sort().join() === [...b].sort().join();
|