@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.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/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +759 -2
- package/dist/app.d.ts +384 -0
- package/dist/app.js +1148 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +354 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.js +171 -0
- package/dist/handlers/agentHandlers.d.ts +18 -0
- package/dist/handlers/agentHandlers.js +20 -0
- package/dist/handlers/assetHandlers.d.ts +29 -0
- package/dist/handlers/assetHandlers.js +35 -0
- package/dist/handlers/capabilities.d.ts +26 -0
- package/dist/handlers/capabilities.js +138 -0
- package/dist/handlers/completionHandlers.d.ts +18 -0
- package/dist/handlers/completionHandlers.js +13 -0
- package/dist/handlers/configHandlers.d.ts +31 -0
- package/dist/handlers/configHandlers.js +59 -0
- package/dist/handlers/conversationHandlers.d.ts +118 -0
- package/dist/handlers/conversationHandlers.js +314 -0
- package/dist/handlers/handoffHandlers.d.ts +17 -0
- package/dist/handlers/handoffHandlers.js +14 -0
- package/dist/handlers/hostHandlers.d.ts +39 -0
- package/dist/handlers/hostHandlers.js +56 -0
- package/dist/handlers/index.d.ts +128 -0
- package/dist/handlers/index.js +92 -0
- package/dist/handlers/messageHandlers.d.ts +11 -0
- package/dist/handlers/messageHandlers.js +10 -0
- package/dist/handlers/modelHandlers.d.ts +17 -0
- package/dist/handlers/modelHandlers.js +19 -0
- package/dist/handlers/noticeHandlers.d.ts +24 -0
- package/dist/handlers/noticeHandlers.js +24 -0
- package/dist/handlers/questionHandlers.d.ts +16 -0
- package/dist/handlers/questionHandlers.js +16 -0
- package/dist/handlers/searchHandlers.d.ts +12 -0
- package/dist/handlers/searchHandlers.js +12 -0
- package/dist/handlers/workspaceHandlers.d.ts +29 -0
- package/dist/handlers/workspaceHandlers.js +47 -0
- package/dist/i18n/en.d.ts +243 -0
- package/dist/i18n/en.js +270 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.js +72 -0
- package/dist/i18n/zh.d.ts +240 -0
- package/dist/i18n/zh.js +257 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/net/UploadReceiver.d.ts +186 -0
- package/dist/net/UploadReceiver.js +494 -0
- package/dist/net/WsHub.d.ts +231 -0
- package/dist/net/WsHub.js +689 -0
- package/dist/net/auth.d.ts +73 -0
- package/dist/net/auth.js +74 -0
- package/dist/net/discovery.d.ts +72 -0
- package/dist/net/discovery.js +171 -0
- package/dist/net/index.d.ts +14 -0
- package/dist/net/index.js +10 -0
- package/dist/runtime/ChatRunner.d.ts +374 -0
- package/dist/runtime/ChatRunner.js +1204 -0
- package/dist/runtime/ConversationDrafts.d.ts +43 -0
- package/dist/runtime/ConversationDrafts.js +47 -0
- package/dist/runtime/RunRegistry.d.ts +79 -0
- package/dist/runtime/RunRegistry.js +108 -0
- package/dist/runtime/ScopeRegistry.d.ts +153 -0
- package/dist/runtime/ScopeRegistry.js +259 -0
- package/dist/runtime/TransientRunner.d.ts +90 -0
- package/dist/runtime/TransientRunner.js +179 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.js +10 -0
- package/dist/services/AgentService.d.ts +150 -0
- package/dist/services/AgentService.js +718 -0
- package/dist/services/AssetService.d.ts +338 -0
- package/dist/services/AssetService.js +739 -0
- package/dist/services/AutoNaming.d.ts +92 -0
- package/dist/services/AutoNaming.js +235 -0
- package/dist/services/CompletionService.d.ts +122 -0
- package/dist/services/CompletionService.js +442 -0
- package/dist/services/ConfigService.d.ts +171 -0
- package/dist/services/ConfigService.js +695 -0
- package/dist/services/ConversationService.d.ts +274 -0
- package/dist/services/ConversationService.js +902 -0
- package/dist/services/CredentialService.d.ts +33 -0
- package/dist/services/CredentialService.js +86 -0
- package/dist/services/DiscoveryService.d.ts +182 -0
- package/dist/services/DiscoveryService.js +998 -0
- package/dist/services/HandoffService.d.ts +189 -0
- package/dist/services/HandoffService.js +414 -0
- package/dist/services/Logger.d.ts +130 -0
- package/dist/services/Logger.js +256 -0
- package/dist/services/MessageService.d.ts +88 -0
- package/dist/services/MessageService.js +280 -0
- package/dist/services/ModelService.d.ts +89 -0
- package/dist/services/ModelService.js +489 -0
- package/dist/services/NoticeService.d.ts +93 -0
- package/dist/services/NoticeService.js +217 -0
- package/dist/services/QuestionService.d.ts +184 -0
- package/dist/services/QuestionService.js +457 -0
- package/dist/services/SearchService.d.ts +54 -0
- package/dist/services/SearchService.js +117 -0
- package/dist/services/WorkspaceService.d.ts +96 -0
- package/dist/services/WorkspaceService.js +213 -0
- package/dist/services/imageSize.d.ts +41 -0
- package/dist/services/imageSize.js +146 -0
- package/dist/services/index.d.ts +20 -0
- package/dist/services/index.js +15 -0
- package/dist/services/secretMask.d.ts +24 -0
- package/dist/services/secretMask.js +34 -0
- package/dist/storage/AssetRepository.d.ts +87 -0
- package/dist/storage/AssetRepository.js +180 -0
- package/dist/storage/ConversationRepository.d.ts +55 -0
- package/dist/storage/ConversationRepository.js +128 -0
- package/dist/storage/CredentialRepository.d.ts +28 -0
- package/dist/storage/CredentialRepository.js +47 -0
- package/dist/storage/Database.d.ts +95 -0
- package/dist/storage/Database.js +180 -0
- package/dist/storage/MessageRepository.d.ts +104 -0
- package/dist/storage/MessageRepository.js +323 -0
- package/dist/storage/MetaRepository.d.ts +21 -0
- package/dist/storage/MetaRepository.js +33 -0
- package/dist/storage/NoticeRepository.d.ts +31 -0
- package/dist/storage/NoticeRepository.js +89 -0
- package/dist/storage/QuestionRepository.d.ts +34 -0
- package/dist/storage/QuestionRepository.js +96 -0
- package/dist/storage/WorkspaceRepository.d.ts +25 -0
- package/dist/storage/WorkspaceRepository.js +44 -0
- package/dist/storage/dbWorker.d.ts +10 -0
- package/dist/storage/dbWorker.js +132 -0
- package/dist/storage/index.d.ts +20 -0
- package/dist/storage/index.js +16 -0
- package/dist/storage/migrations.d.ts +26 -0
- package/dist/storage/migrations.js +207 -0
- package/dist/storage/types.d.ts +298 -0
- package/dist/storage/types.js +14 -0
- package/dist/wire/errors.d.ts +105 -0
- package/dist/wire/errors.js +143 -0
- package/dist/wire/events.d.ts +102 -0
- package/dist/wire/events.js +13 -0
- package/dist/wire/frames.d.ts +106 -0
- package/dist/wire/frames.js +156 -0
- package/dist/wire/index.d.ts +31 -0
- package/dist/wire/index.js +31 -0
- package/dist/wire/methods.d.ts +1896 -0
- package/dist/wire/methods.js +99 -0
- package/dist/wire/states.d.ts +487 -0
- package/dist/wire/states.js +61 -0
- package/package.json +63 -3
package/README.md
CHANGED
|
@@ -1,3 +1,760 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ai-zen/agents-host
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AI-Zen Agents Host — the session/workspace service shared by CLI and Desktop.
|
|
4
|
+
|
|
5
|
+
Both front-ends should only *render and read input*; session orchestration, capability
|
|
6
|
+
assembly, persistence and task migration live here.
|
|
7
|
+
|
|
8
|
+
## Source of Truth
|
|
9
|
+
|
|
10
|
+
Dependency direction (one-way, never reversed):
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
@ai-zen/cli ─────┐
|
|
14
|
+
├──→ @ai-zen/agents-host ──→ @ai-zen/agents-sdk ──→ @ai-zen/agents-core
|
|
15
|
+
desktop/main ────┘
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Both front ends reach this package through `@ai-zen/agents-client`. The wire contract is **not a
|
|
19
|
+
package**: it is this package's own type files, `src/protocol/` (types only — method/event name
|
|
20
|
+
unions, payload types, error-code unions), exposed as `@ai-zen/agents-host/protocol` for
|
|
21
|
+
`import type`. Everything with runtime behaviour (frame codec, error-code predicate, loopback
|
|
22
|
+
predicate, version constant, discovery-file I/O, `callOnce`, the credential file's read-and-generate
|
|
23
|
+
in `token`) lives in `src/wire/` on this side — and each client has its own copy under
|
|
24
|
+
`packages/client/src/wire/`. Only types are shared.
|
|
25
|
+
|
|
26
|
+
`sdk` and `core` must never import this package:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
grep -rn "agents-host" packages/sdk/src packages/core/src # must be empty
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## The contract: `src/protocol/` (types) and `src/wire/` (runtime)
|
|
33
|
+
|
|
34
|
+
The wire contract is **not a package** — it is this package's type files. Two directories, two jobs:
|
|
35
|
+
|
|
36
|
+
| | Holds | Runtime exports |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `src/protocol/` | method/event **name unions**, payload types, error-code unions, message shapes, phase shapes, the type-only re-export of the data model | **none** — every file is `type`/`interface` only |
|
|
39
|
+
| `src/wire/` | message codec (one JSON-RPC 2.0 message per WS message), error-code predicate (`codedError` / `isCodedError` / `codeOf`), loopback predicate, `PROTOCOL_VERSION`, discovery-file I/O, the credential file's path / read / generate (`tokenPath` / `readToken` / `ensureToken`), `callOnce`, `DEFAULT_MIGRATION_STRATEGY` | yes |
|
|
40
|
+
|
|
41
|
+
Clients import **only** `@ai-zen/agents-host/protocol` and only with `import type`; their own
|
|
42
|
+
runtime copies live in `packages/client/src/wire/`. Nothing is shared but shapes and names — which
|
|
43
|
+
is why `@ai-zen/agents-client` has zero runtime dependency on this package (the host is its
|
|
44
|
+
**peerDependency**: the host must provide it for the type faces and for spawning the bin; the SDK is
|
|
45
|
+
a peer for the same reason, `ConfigPort`'s public signature needs its config and agent shapes).
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# the type face must stay free of runtime exports
|
|
49
|
+
grep -rn '^export \(const\|function\|class\|async function\)' packages/host/src/protocol # must be empty
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Running the daemon
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
agents-host # foreground on 127.0.0.1:8790; a local instance mints <aiZenDir>/run/token
|
|
56
|
+
agents-host --port 0 # let the OS pick a free port
|
|
57
|
+
agents-host --host 127.0.0.1 --port 9000
|
|
58
|
+
agents-host --public # outward-facing instance: no run/token, mint credentials yourself
|
|
59
|
+
agents-host --ai-zen-dir ~/.ai-zen # data root (default ~/.ai-zen)
|
|
60
|
+
|
|
61
|
+
agents-host token create --note laptop # mint a credential; plaintext is shown once
|
|
62
|
+
agents-host token list # every credential, revoked ones included
|
|
63
|
+
agents-host token revoke <credential id>
|
|
64
|
+
agents-host version # version + protocol version
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Port resolution has three sources, in order: `--port` > `port` in `<aiZenDir>/host.json` > 8790.
|
|
68
|
+
`--port 0` asks the OS for a free port, which is what tests and smoke runs use.
|
|
69
|
+
|
|
70
|
+
Clients find it through `<aiZenDir>/run/host.json` (written atomically; removed on a clean
|
|
71
|
+
stop). The file carries `pid` + `version` + `protocol`, so a client can tell "crashed
|
|
72
|
+
leftover" from "different build still running" before it even connects. Logs go to
|
|
73
|
+
`<aiZenDir>/logs/host.log` — a daemon must never write to the client's terminal.
|
|
74
|
+
|
|
75
|
+
### Loopback only, with a token and an origin check in front
|
|
76
|
+
|
|
77
|
+
`Daemon.start()` accepts only loopback hosts — the whole `127.0.0.0/8` block, `::1` and
|
|
78
|
+
`localhost`, judged by `isLoopbackHost()` in `src/wire/host.ts` — and throws for anything else.
|
|
79
|
+
That is not "loopback because there is no auth" and not a placeholder to be relaxed casually: the
|
|
80
|
+
service exposes `exec` (arbitrary command execution), so putting such a port straight onto a
|
|
81
|
+
network is not the default this project wants. Remote access goes through **tunnels or a reverse
|
|
82
|
+
proxy** (nginx on the same machine proxying to `127.0.0.1`, which lands exactly on that assertion);
|
|
83
|
+
the host **never implements TLS and never binds a non-loopback address** — `wss://` is a *client*
|
|
84
|
+
capability, not a host mode. See `docs/zh/deploy.md` / `docs/en/deploy.md`.
|
|
85
|
+
|
|
86
|
+
What guards the port is a **credential**, not the absence of listeners:
|
|
87
|
+
|
|
88
|
+
- every connection's first request must be `hello`, and `params.token` is **required** in it;
|
|
89
|
+
- the token is an opaque random string (32 random bytes, base64url). `start()` reuses
|
|
90
|
+
`<aiZenDir>/run/token` if it exists and generates it (mode 0600, atomic `.tmp` + `rename`)
|
|
91
|
+
otherwise — one source of truth per machine, shared with every client on it. It deliberately
|
|
92
|
+
does **not** live in `host.json`: that file is diagnostic and gets pasted into issues;
|
|
93
|
+
- a wrong token is **`unauthorized`** (`-32000`) and a malformed one (`token` missing / not a
|
|
94
|
+
string) is `bad-params` (`-32602`); neither drops the connection — a client fixes the field or
|
|
95
|
+
the key and continues on the same connection. Hanging up would also tell a scanner that
|
|
96
|
+
something really is listening here;
|
|
97
|
+
- the comparison is constant-time (`timingSafeEqual`, after a length check), and the token itself
|
|
98
|
+
never reaches a log line.
|
|
99
|
+
|
|
100
|
+
Browsers get a second, independent gate: the upgrade is refused with **HTTP 403** unless the request
|
|
101
|
+
has no `Origin` at all (terminals, the desktop app, scripts), or its origin's hostname is loopback
|
|
102
|
+
(the port is irrelevant), or its origin is listed in `AI_ZEN_ALLOWED_ORIGINS`. It is defence in
|
|
103
|
+
depth — what actually stops a random web page from talking to a bare port is the token, which a page
|
|
104
|
+
cannot read. Refusing before the upgrade is why the answer is an HTTP status rather than a WS close
|
|
105
|
+
code: at that moment there is no WebSocket to close.
|
|
106
|
+
|
|
107
|
+
## Wire protocol
|
|
108
|
+
|
|
109
|
+
**WebSocket + JSON-RPC 2.0**: one WS message is exactly one complete JSON-RPC 2.0 message, in both
|
|
110
|
+
directions, and a text frame carries no trailing newline — the WS message boundary is the only
|
|
111
|
+
framing there is. The version is `3`, and a version that does not match means no connection. The
|
|
112
|
+
full spec (the `id` range, binary frames, size ceilings, authentication, the origin check, the
|
|
113
|
+
error-code mapping and the exact list of cases that drop a connection) is `docs/zh/protocol.md` /
|
|
114
|
+
`docs/en/protocol.md`; this section only says what the wire is.
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
→ {"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocol":3,"token":"<this machine's run/token>","client":{"name":"tui","version":"1.0.2"}}}
|
|
118
|
+
← {"jsonrpc":"2.0","id":1,"result":{"name":"agents-host","version":"0.1.0","protocol":3,"pid":4180641,"startedAt":"..."}}
|
|
119
|
+
→ {"jsonrpc":"2.0","id":2,"method":"ping"}
|
|
120
|
+
← {"jsonrpc":"2.0","id":2,"result":{"pong":true}}
|
|
121
|
+
← {"jsonrpc":"2.0","method":"session.event","params":{"event":"chunk",...}}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Three message classes: a **request** (has `method`; with an `id` it expects a response), a
|
|
125
|
+
**response** (no `method`, has `id`) and a **notification** (has `method`, no `id`). `id`s are
|
|
126
|
+
integers in `0 … 2³²-1`, allocated by the client. This build's host only ever sends
|
|
127
|
+
notifications and never initiates requests, so the two directions' ids cannot collide.
|
|
128
|
+
|
|
129
|
+
Images and files do **not** ride inside `session.send`: `content.upload` is one call spread over
|
|
130
|
+
two WS messages — the ordinary text request, then a binary message whose first four bytes are that
|
|
131
|
+
request's `id` and whose remaining bytes are the payload.
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
→ {"jsonrpc":"2.0","id":7,"method":"content.upload","params":{"sessionId":"chat_1","mime":"image/png","filename":"a.png"}}
|
|
135
|
+
→ <binary: 4-byte id 7, then the raw bytes>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`params` carries `sessionId` / `mime` / `filename` (all three required) and **no** length field —
|
|
139
|
+
the payload is exactly as long as the binary message is. Why a separate binary message instead of
|
|
140
|
+
base64 inside the JSON: base64 costs a third more bytes and forces the whole payload through a
|
|
141
|
+
string. The bytes are found by `id`, never by position, and WS ordering puts them after their
|
|
142
|
+
request frame; a binary message whose `id` matches no in-flight request drops the connection. Only
|
|
143
|
+
the methods in `BINARY_METHODS` (`src/daemon/Daemon.ts`) may carry a payload.
|
|
144
|
+
|
|
145
|
+
The content block that goes into `session.send` is produced **host-side**
|
|
146
|
+
(`content.upload` → SDK `fileToContentSection`): whether those bytes get uploaded as a `file_id`,
|
|
147
|
+
inlined as base64, or degraded to a text hint depends on the endpoint and model **that session**
|
|
148
|
+
is actually using. A JSON-RPC text message carrying such a block still has to fit under the 8 MiB
|
|
149
|
+
text-frame ceiling — the inline case puts base64 back into the JSON message — so clients keep
|
|
150
|
+
measuring it.
|
|
151
|
+
|
|
152
|
+
Built-in methods: `ping`, `status`, `shutdown`. Everything else is injected by the
|
|
153
|
+
composition root through `DaemonOptions.handlers`, so the daemon skeleton stays free of
|
|
154
|
+
business logic.
|
|
155
|
+
|
|
156
|
+
### The transport is one seam: `Daemon` never touches a socket
|
|
157
|
+
|
|
158
|
+
`Daemon`'s connection entry point takes a `Wire` (`src/daemon/wire.ts`), so how bytes actually
|
|
159
|
+
arrive is not the daemon's business — and neither is framing: a WebSocket message *is* a frame, so
|
|
160
|
+
there is no line splitting and no declared-length accumulation anywhere in this package. A text
|
|
161
|
+
message is decoded as exactly one JSON-RPC message; a binary message is a payload whose first four
|
|
162
|
+
bytes are the `id` of the in-flight request it belongs to.
|
|
163
|
+
|
|
164
|
+
The interface is settled: **seven members — six methods plus one property**, each of them counted
|
|
165
|
+
from what `Daemon` actually calls, no more and no fewer (a member is an obligation every transport
|
|
166
|
+
has to implement and test).
|
|
167
|
+
|
|
168
|
+
| Member | Kind | What it is |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `destroyed` | property | can the peer still receive? `Daemon` checks it before writing (writing a closed connection is a silent no-op) |
|
|
171
|
+
| `onText(listener)` | method | inbound: one text message, already a string. The boundary is the transport's job; whether it parses as JSON is the daemon's (`bad-frame`) |
|
|
172
|
+
| `onBinary(listener)` | method | inbound: one binary message — its first four bytes are the request `id`, the rest is the payload, handed over as a raw `Buffer` (one UTF-8 round trip would corrupt an image without failing) |
|
|
173
|
+
| `onError(listener)` | method | the transport itself failed (reset, write error); `Daemon` logs one warn line. It has to be in the interface: `ws` throws when an `error` event has no listener, and folding it into `onClose` would lose that log line |
|
|
174
|
+
| `onClose(listener)` | method | the connection is gone for good, exactly once — the connection table, the `events.watch` unsubscribes and the idle timer all key off it |
|
|
175
|
+
| `write(text)` | method | outbound: one text message. Outbound has this single door; the host sends no binary (all of its results are JSON) |
|
|
176
|
+
| `destroy()` | method | drop this connection; implemented as `ws.close()` — a graceful close, so an error response sent just before it still reaches the peer |
|
|
177
|
+
|
|
178
|
+
There is no unsubscribe handle: a wire lives exactly as long as its connection, and `Daemon` never
|
|
179
|
+
swaps its callbacks mid-connection.
|
|
180
|
+
|
|
181
|
+
`start()` serves HTTP, accepts the upgrade, and hands each connection to
|
|
182
|
+
`Daemon.handleConnection(wire)`. The credential check does not live in `start()`'s listener but in
|
|
183
|
+
the connection handler — `hello` is where the token rides — while the origin check does live in the
|
|
184
|
+
listener: it has to answer with HTTP 403 before the WebSocket exists.
|
|
185
|
+
|
|
186
|
+
That "not a socket" claim is tested, not hoped for. `src/daemon/wire.test.ts` drives the same
|
|
187
|
+
`handleConnection()` twice: once through an **in-memory duplex** (a real object implementing `Wire`
|
|
188
|
+
over two `PassThrough` streams), and once with a **real WebSocket client** — `WsTestClient`
|
|
189
|
+
(`src/daemon/WsTestClientTestSupport.ts`) connects to the daemon over `ws://` and speaks JSON-RPC
|
|
190
|
+
through the very same `webSocketWire()` adapter and upgrade callback that `start()` uses. Both
|
|
191
|
+
halves handshake with a **token injected into the `Daemon`** (never read from a file, so no test
|
|
192
|
+
depends on whatever happens to be on disk). The second half walks the same scene — handshake / call /
|
|
193
|
+
event push / close cleanup — while the in-memory half additionally covers binary pairing, the
|
|
194
|
+
request-level errors that must not drop the connection, and the `unauthorized` path — so "the same
|
|
195
|
+
thing over another transport" is checked rather than asserted. `WsTestClient`
|
|
196
|
+
is what `Daemon.test.ts` and `sessionHandlers.test.ts` use as well, and it is a real socket on
|
|
197
|
+
purpose: what those suites test is the wire itself, so nothing is mocked.
|
|
198
|
+
|
|
199
|
+
## Session layer
|
|
200
|
+
|
|
201
|
+
`src/protocol/sessions.ts` is the wire contract for sessions — method names, params and the
|
|
202
|
+
events, as three name unions: `AgentEventName` (core's own eight: `open`, `chunk`, `parsed`,
|
|
203
|
+
`tool-call-end`, `error`, `finally`, `sub-agent-start`, `sub-agent-end`), `SessionEventName`
|
|
204
|
+
(`session-phase`, `session-turn-end`, `session-notice`, `session-question`,
|
|
205
|
+
`session-id-changed`, `session-title-changed`) and `HostEventName` (`host-notice`).
|
|
206
|
+
`src/domain/session/` holds the session as four collaborating classes:
|
|
207
|
+
`SessionManager.ts` (live-session registry, holders/reclaiming, turn lifecycle, catalogue,
|
|
208
|
+
handoff), `EventBridge.ts` (SDK events → protocol events), `Persistence.ts` (the archive shape
|
|
209
|
+
and every write path), `QuestionBroker.ts` (host → client questions). `src/lib/` holds the
|
|
210
|
+
business-free parts (`phase.ts` = phase state machine, `events.ts` = connection fan-out).
|
|
211
|
+
`src/domain/agent/` assembles the agent (config + Scope + capability
|
|
212
|
+
discovery) per workspace; `src/daemon/sessionHandlers.ts` registers the session and workspace
|
|
213
|
+
methods, `configHandlers.ts` / `agentHandlers.ts` / `assetHandlers.ts` / `contentHandlers.ts` the
|
|
214
|
+
rest, and `src/cli.ts` wires all of it.
|
|
215
|
+
|
|
216
|
+
**The method list has exactly one source of truth: `MethodApi` in `src/protocol/api.ts`** — one
|
|
217
|
+
entry per method, binding each name to its params and result. The name unions it must agree with
|
|
218
|
+
(`SessionMethod` / `WorkspaceMethod` / `EventMethod` / `ConfigMethod` / `AgentMethod` /
|
|
219
|
+
`AssetMethod` / `ContentMethod` / `DaemonMethod`) live in `sessions.ts` / `config.ts` / `assets.ts` /
|
|
220
|
+
`content.ts` / `daemon.ts`,
|
|
221
|
+
and two `Assert`s in `api.ts` fail the build if the two ever disagree. This README deliberately
|
|
222
|
+
does not copy the list: a copy is what goes stale, and a stale list is worse than no list. What
|
|
223
|
+
each method means is documented where it is defined; the sections below only carry the semantics
|
|
224
|
+
that need more room than a doc comment.
|
|
225
|
+
|
|
226
|
+
Agent assembly is **per workspace**: one Scope (one set of MCP connections and tool
|
|
227
|
+
discovery) is shared by every session in the same workspace, and never shared across
|
|
228
|
+
workspaces — project-level skills / tools / mcp hang off the workspace path.
|
|
229
|
+
|
|
230
|
+
Two things happen with tool plugins, and only one of them is unconditional: `AutoRefreshToolsPlugin`
|
|
231
|
+
is installed **regardless of token thresholds** (re-discovers capabilities so a host/skill/tool
|
|
232
|
+
added mid-session becomes visible), while guard / sub-agent guard / auto-migration come *after*
|
|
233
|
+
that check — they only apply when the model declares `tokenThresholds`, and returning early on
|
|
234
|
+
"no thresholds" must not silently disable tool refresh too. The refresh timing is a global config
|
|
235
|
+
knob (`autoRefreshTools`: `inner-loop-start` — default, before every request — /
|
|
236
|
+
`outer-loop-start` — once per turn — / `off`), because it trades freshness against one capability
|
|
237
|
+
scan per request.
|
|
238
|
+
|
|
239
|
+
### The workspace owns the cwd — and the agent's tool cwd
|
|
240
|
+
|
|
241
|
+
`workspace.resolve` takes an **optional** path, and who sends it depends on where the host
|
|
242
|
+
runs:
|
|
243
|
+
|
|
244
|
+
- **Client on the same machine (loopback)** — the client sends its own working directory. That
|
|
245
|
+
is the user's actual intent ("I typed `ai` in this project") and it is what makes "reuse or
|
|
246
|
+
create *this* directory's workspace, then resume its last conversation" work. The client only
|
|
247
|
+
says *"I would like this directory"*; the host still normalises it, and the client keeps the
|
|
248
|
+
**returned** path (the CLI's `reset()` uses it to open the next session in the same workspace).
|
|
249
|
+
- **Remote host** — nothing is sent, and the host answers with *its own* working directory:
|
|
250
|
+
the `--workspace` flag, else the directory the daemon was started from (logged at startup, so
|
|
251
|
+
"who decided this directory" is always answerable from the log). A laptop's cwd is a directory
|
|
252
|
+
the host may not even have.
|
|
253
|
+
|
|
254
|
+
The handler never reads the process cwd itself — the composition root resolves the default once
|
|
255
|
+
and injects it as `defaultWorkspacePath`. The *client* makes the same call for project-scoped
|
|
256
|
+
`mcp.json` (`local-host.ts` in `@ai-zen/agents-client` is the single judge), because two separate
|
|
257
|
+
judgements would eventually disagree: the session in project A, the MCP config read from
|
|
258
|
+
somewhere else.
|
|
259
|
+
|
|
260
|
+
**The workspace path must reach `Scope` as its `cwd`.** `Scope` defaults to the *process's*
|
|
261
|
+
current directory, which on a daemon is wherever it happened to be spawned — one missing
|
|
262
|
+
argument gives you `@`-completion listing workspace B's files while `exec pwd` and every
|
|
263
|
+
relative read/write land in directory A. `AgentPaths.workspacePath` exists for exactly this.
|
|
264
|
+
|
|
265
|
+
### The workspace is a first-class object: named, id frozen, relocatable
|
|
266
|
+
|
|
267
|
+
`<aiZenDir>/workspaces/<workspaceId>/workspace.json` is the identity, and the id is **assigned
|
|
268
|
+
once and frozen** (`{ schema, id, path, title?, createdAt }`). `workspaceIdFor(path)`
|
|
269
|
+
(`<dirname>-<sha256(path)[0..8]>`) is only the *candidate* for the first assignment:
|
|
270
|
+
|
|
271
|
+
1. **fast path** — the record behind `workspaceIdFor(path)` exists **and** its `path` matches →
|
|
272
|
+
use it. One file read, no directory scan; this is what a client startup hits.
|
|
273
|
+
2. **slow path** — otherwise scan `<workspacesRoot>` for a record whose `path` is this path and
|
|
274
|
+
reuse **that** id. This is the only way back in after `mv ~/proj ~/proj2` (the frozen id is no
|
|
275
|
+
longer `workspaceIdFor(currentPath)`), i.e. it is what keeps "renamed a directory ⇒ history
|
|
276
|
+
gone" from happening.
|
|
277
|
+
3. **new** — neither matched → write a record with the derived id (shape unchanged). If that id
|
|
278
|
+
is already held by *another* path (which relocation makes reachable, since ids no longer
|
|
279
|
+
follow paths), take the next free one (`<id>-2`, …) instead of overwriting the record.
|
|
280
|
+
|
|
281
|
+
The path is compared only after `resolve()`, so relative paths, trailing slashes and `..` all
|
|
282
|
+
converge on one workspace.
|
|
283
|
+
|
|
284
|
+
Lifecycle methods, and the one place the path must still exist:
|
|
285
|
+
|
|
286
|
+
- `workspace.list` → `{ workspaceId, title?, path, pathExists }[]`, sorted by display name
|
|
287
|
+
(`title ?? basename(path)`) then id — a total order, so two calls never disagree. `pathExists:
|
|
288
|
+
false` means the directory was renamed/deleted/moved and the client should offer a repair.
|
|
289
|
+
- `workspace.rename` changes **only** `title` (id and path untouched; empty title is
|
|
290
|
+
`bad-params`) — same name and meaning as `session.rename`.
|
|
291
|
+
- `workspace.relocate` rewrites `path`, keeping the id, so the sessions under that id become
|
|
292
|
+
reachable again. The new path must already exist (`workspace-path-missing` — the host never
|
|
293
|
+
`mkdir`s for the user) and must not be claimed by another record (`workspace-exists`).
|
|
294
|
+
- `workspace.remove` deletes `<workspacesRoot>/<id>/` **including `sessions/` and the pointer** —
|
|
295
|
+
"delete this workspace and all its conversations" — and closes the live sessions of that
|
|
296
|
+
workspace (otherwise their next turn would write the archive back and partly resurrect it).
|
|
297
|
+
Idempotent: `removed: false` when it was not there.
|
|
298
|
+
|
|
299
|
+
`session.create` requires the **recorded** workspace path to still exist: with the directory gone
|
|
300
|
+
it fails with `workspace-path-missing` (message carries the path) instead of quietly creating an
|
|
301
|
+
empty directory the user would mistake for "resumed my project". (`workspace.relocate` also
|
|
302
|
+
requires a path to exist — its *new* one.) Read-only methods
|
|
303
|
+
(`session.attach` / `last` / `list` / `snapshot`, and `workspace.resolve` / `list` / `rename` /
|
|
304
|
+
`remove`) keep working: history lives in `workspaces/<id>/sessions/`, which has nothing to do
|
|
305
|
+
with whether that working directory still exists — and those are exactly the methods needed to
|
|
306
|
+
repair the record.
|
|
307
|
+
|
|
308
|
+
### Repairing a broken workspace is the **client's** job (deliberate)
|
|
309
|
+
|
|
310
|
+
When `workspace.list` reports `pathExists: false`, the host deliberately does **not** try to
|
|
311
|
+
guess where the directory went — not by basename, not by inode, not by "it looked like the same
|
|
312
|
+
project". The contract is:
|
|
313
|
+
|
|
314
|
+
- a GUI (desktop) shows the row with a warning and asks the user to **pick the new path**
|
|
315
|
+
(`workspace.relocate`) or to **re-create the folder** at the recorded path (after which
|
|
316
|
+
`pathExists` is true again and nothing else needs to happen);
|
|
317
|
+
- a CLI does **not** handle it at all: its workspace is `cwd`, the user typing `ai` in a moved
|
|
318
|
+
directory is indistinguishable from a genuinely new project, and a wrong guess would silently
|
|
319
|
+
attach someone's history to an unrelated directory — which is worse than an empty session.
|
|
320
|
+
|
|
321
|
+
Auto-detection was considered and rejected: inode identity only holds within one filesystem
|
|
322
|
+
(a copy+delete move breaks it), network filesystems may not keep it stable, and "the basename
|
|
323
|
+
looks the same" is a prediction, not a fact. One row that says "broken, fix me" is honest;
|
|
324
|
+
a silent wrong answer is not.
|
|
325
|
+
|
|
326
|
+
`workspace.files` returns the candidate list behind the client's `@`-completion. Entries are
|
|
327
|
+
relative to the workspace root, `/`-separated, and carry `dir` so the client knows whether to
|
|
328
|
+
append a trailing `/`. It is produced from **the host's** disk: `git ls-files -co
|
|
329
|
+
--exclude-standard` first (one call gives tracked + untracked *and* respects `.gitignore`),
|
|
330
|
+
`fs.glob` as the fallback for non-repos, 15s TTL cache keyed by workspace root, 20k entry cap.
|
|
331
|
+
Two details are load-bearing:
|
|
332
|
+
|
|
333
|
+
- **The root comes from the `workspace.json` record, never from the client.** The client only
|
|
334
|
+
sends `workspaceId`; the id is a hash and cannot be reversed into a path, so the record is
|
|
335
|
+
the only way back. A `workspaceId` that would escape the workspaces root is rejected.
|
|
336
|
+
- **Ancestor directories are synthesized** (`src/tui/chat.tsx` → `src`, `src/tui`). Without
|
|
337
|
+
them the menu cannot be drilled into (`@src/` → keep listing), so it is a capability
|
|
338
|
+
difference, not list padding.
|
|
339
|
+
|
|
340
|
+
### Capabilities on disk: `asset.list`
|
|
341
|
+
|
|
342
|
+
`asset.list { kind: "skill" | "tool", scope?, workspaceId? }` returns what capability discovery
|
|
343
|
+
would find — a **read-only** view (no CRUD lives here: assets are the user's files, and changing
|
|
344
|
+
them is a different action with its own permission story). It does **not** re-implement the scan:
|
|
345
|
+
`agentPaths.assetDirs` says which directories belong to which layer, and the SDK's
|
|
346
|
+
`discoverSkills` / `discoverUserToolFiles` do the scanning, so the panel and the agent can never
|
|
347
|
+
disagree about what is installed. Each entry carries its own `source` (`global` / `project`),
|
|
348
|
+
because `scope` defaults to *both* layers (project wins on a name clash, same priority order as
|
|
349
|
+
discovery). `scope: "project"` without a `workspaceId` is `bad-params`: project-level assets hang
|
|
350
|
+
off a workspace path, so there is no \"empty list\" answer — the caller forgot an argument. Without
|
|
351
|
+
a `workspaceId` you simply get the global layer only. One asymmetry worth knowing: `path` is a
|
|
352
|
+
**directory** for a skill (multi-file unit, `SKILL.md` inside) and a **file** for a tool; and
|
|
353
|
+
listing tools really does `import()` them (that is how discovery loads `Tool` instances).
|
|
354
|
+
|
|
355
|
+
### Two rounds, one session: `create` vs `attach`
|
|
356
|
+
|
|
357
|
+
`session.create` means **a new session** — if the id is already live it refuses
|
|
358
|
+
(`session-exists`), so that a client cannot silently take over a turn that is running.
|
|
359
|
+
`session.attach` means **that existing session**: live → join it, on disk → reload it from the
|
|
360
|
+
archive (`session-missing` if neither). The distinction exists because a session outlives its
|
|
361
|
+
clients: closing the terminal only drops the event subscription, while the session (and its
|
|
362
|
+
Agent and MCP connections) keeps running. Resuming "the last conversation" therefore arrives at
|
|
363
|
+
a *live* id, and `create` refuses a live id by design — so resume has to go through `attach`.
|
|
364
|
+
|
|
365
|
+
`send` has a matching guard: while a turn is in flight, a second `send` is rejected with
|
|
366
|
+
`turn-busy`. Events are attributed by reading `session.turnId` **at emit time**, so two
|
|
367
|
+
concurrent turns would interleave their deltas under each other's ids.
|
|
368
|
+
|
|
369
|
+
### `session.snapshot`: one resolution, three model facts
|
|
370
|
+
|
|
371
|
+
A reconnecting client gets one screenful of facts from `session.snapshot` instead of replaying
|
|
372
|
+
the event stream. The model part is a **single resolution's** output: `modelId` is the model
|
|
373
|
+
actually in use, `modelLevel` says which of the three levels it came from (`session` >
|
|
374
|
+
`agent` > `global`), and `modelParams` + `modelParamsLevel` are the params that same resolution
|
|
375
|
+
picked (`resolveModelParams` is keyed by the level `resolveModel` just produced — one call each,
|
|
376
|
+
no second pass). **`modelParams` and `modelLevel` come from the same resolution**, so a client
|
|
377
|
+
must not re-derive the level from "effective value + definition + default": that is a second
|
|
378
|
+
implementation of the priority order, and the first symptom is a settings screen showing "high"
|
|
379
|
+
while the request goes out with the old model's `thinking` block.
|
|
380
|
+
|
|
381
|
+
Two things worth knowing about `modelParams`:
|
|
382
|
+
|
|
383
|
+
- It is the **override layer**, not the merged request body — `session`'s copy when the model
|
|
384
|
+
came from the session, the definition's `modelParams` when it came from the agent, and absent
|
|
385
|
+
otherwise (the model's own `defaultParams` then apply). `global` has nowhere to store params,
|
|
386
|
+
so that level never reports any — never borrowed from another level, because a session-level
|
|
387
|
+
model sent with the previous model's params is a 400.
|
|
388
|
+
- `modelParamsLevel` travels with it (both present or both absent) and carries the same value as
|
|
389
|
+
`modelLevel`: the pair exists so a client can show "this value" and "set at this level"
|
|
390
|
+
together without inferring one from the other.
|
|
391
|
+
|
|
392
|
+
### Asking the client something: `session-question`
|
|
393
|
+
|
|
394
|
+
Some decisions belong to the user, not to the host — auto-migration's "who writes the handoff
|
|
395
|
+
document" (`self` | `summary` | skip) is the first one. The event is deliberately **generic**:
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
session-question { questionId, kind, input, options?, params, allowSkip, fallback }
|
|
399
|
+
session.answer { questionId, choice: string | null }
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
- `kind` says *what* is being asked; the client looks up its own wording (`kind + option id`)
|
|
403
|
+
and its own picker. Adding a second kind is a client-side table entry, no protocol change.
|
|
404
|
+
- `params` carries whatever the client needs to render a title (for handoff-mode today
|
|
405
|
+
`{ promptTokens, handoffTokens }`) — a bag, not fixed fields, because the next question will want others.
|
|
406
|
+
- `fallback` is **what the host will use when nobody answers** (timeout, no watcher, or a
|
|
407
|
+
client that does not know this `kind`). The asking party supplies it: only it knows which way
|
|
408
|
+
is safe. For handoff-mode it is `null` = *do nothing*: deciding whether to migrate on the
|
|
409
|
+
user's behalf is exactly the kind of overreach this API exists to avoid. Nobody is asked
|
|
410
|
+
on the user's behalf — the hard guard still interrupts a turn that grows past the limit.
|
|
411
|
+
|
|
412
|
+
Three rules make waiting safe, and each has a reason:
|
|
413
|
+
|
|
414
|
+
- **Nobody watching ⇒ don't ask.** With no event subscribers the question is invisible, so
|
|
415
|
+
`fallback` is returned immediately. Asking a question no one can see only wedges the turn.
|
|
416
|
+
- **Register before emitting.** Handlers run synchronously, so a fast client may answer inside
|
|
417
|
+
the same event-loop tick.
|
|
418
|
+
- **Wait, but with a deadline** (`askTimeoutMs`, 5 min by default), and **clear the table on all
|
|
419
|
+
three exits** (answered / timed out / released by `QuestionBroker.release`). "Not in the table" is
|
|
420
|
+
the single test for "this answer came too late".
|
|
421
|
+
|
|
422
|
+
`release` resolves each pending question with **its own** `fallback` — not with one
|
|
423
|
+
shared value: "the turn is over" says nothing about what the user wanted.
|
|
424
|
+
|
|
425
|
+
### Not archiving is a bug: per-turn persistence
|
|
426
|
+
|
|
427
|
+
`settleTurn` writes the session to the archive **before** `session-turn-end`, then emits. The order is
|
|
428
|
+
the whole point: once a client sees `session-turn-end` it treats the turn as finished, so a session
|
|
429
|
+
missing from disk at that moment means "quit and come back and nothing happened".
|
|
430
|
+
|
|
431
|
+
Three details:
|
|
432
|
+
|
|
433
|
+
- **No user message ⇒ no write.** Otherwise `/load` fills up with empty sessions.
|
|
434
|
+
- **`send` / `handoff` set the flag; `migrate` must not.** `migrate` archives the *full* history
|
|
435
|
+
before pruning; a second write at settle time would overwrite it with the pruned version.
|
|
436
|
+
- **A failed write is a notice (`persistFailed`), not an exception.** The settle path has no
|
|
437
|
+
caller left to throw to, and "you thought it was saved" is the worst failure shape there is.
|
|
438
|
+
|
|
439
|
+
#### One-off sessions opt out: `session.create { persist: false }`
|
|
440
|
+
|
|
441
|
+
Per-turn persistence is right for a conversation, wrong for a *single question*. `ai "…"` (the
|
|
442
|
+
CLI's pipe mode) asks one thing and exits; archiving it would leave a session in `/load` and,
|
|
443
|
+
worse, move the workspace's "last session" pointer — the next TUI launch would resume a script's
|
|
444
|
+
output. So `session.create` takes `persist?: boolean` (default `true`): with `false` the settle
|
|
445
|
+
path skips the write and never touches the pointer. Everything else still holds — it is a live
|
|
446
|
+
session (it is held by the connection that created it), `save` / `saveAs` / migration still
|
|
447
|
+
write to disk when called.
|
|
448
|
+
|
|
449
|
+
The two flags are deliberately separate things and must not be conflated:
|
|
450
|
+
|
|
451
|
+
| flag | scope | question it answers |
|
|
452
|
+
|---|---|---|
|
|
453
|
+
| `persistOnSettle` | one turn | does *this* turn get written (`migrate`'s mid-turn settle says no) |
|
|
454
|
+
| `persist` / `autoPersist` | the session | is *this conversation* worth archiving at all |
|
|
455
|
+
|
|
456
|
+
Archiving a one-off run is an explicit act, and it also must not steal the resume pointer:
|
|
457
|
+
`session.save { sessionId, setAsLast?: boolean }` (default `true`, i.e. the TUI's `/save`)
|
|
458
|
+
writes the file and leaves `last-session.json` alone when `false`.
|
|
459
|
+
|
|
460
|
+
### Who decides a session may be dropped
|
|
461
|
+
|
|
462
|
+
A session outlives its clients, but it must not live forever. One fact decides it:
|
|
463
|
+
|
|
464
|
+
> A session stays only while at least one connection is actually watching it (`holders`).
|
|
465
|
+
> When the last holder goes away it is reclaimed after a grace period (`session.close`
|
|
466
|
+
> reclaims it immediately).
|
|
467
|
+
|
|
468
|
+
- `holders` is what the transport knows for free: `session.create` / `session.attach` register
|
|
469
|
+
the calling connection, and that connection's close removes it. No frame, no client
|
|
470
|
+
cooperation — the kernel guarantees the close (fin/rst on exit, crash or `kill -9`).
|
|
471
|
+
- `holders` is the *only* fact that decides lifetime. There is deliberately no declared
|
|
472
|
+
counterpart: a flag the client had to send over the wire would be ANDed with `holders`, and a
|
|
473
|
+
crashed process never gets to send that frame — used as a necessary condition, a `kill -9`ed
|
|
474
|
+
client would pin its session (and the agent plus message history behind it) in memory until the
|
|
475
|
+
daemon restarted, which is exactly the case `holders` already covers.
|
|
476
|
+
- When the last holder goes away the session gets a grace period (`DEFAULT_SESSION_IDLE_TIMEOUT_MS`,
|
|
477
|
+
60 s, `--session-idle-timeout`). It must cover the client's reconnect budget (~30 s worst case),
|
|
478
|
+
otherwise a reconnecting client finds the session already reclaimed. The deadline **re-checks**
|
|
479
|
+
the rule, so an `attach` arriving inside the window just cancels it, and a turn still running at
|
|
480
|
+
the deadline defers the close to `session-turn-end`.
|
|
481
|
+
- `session.close { sessionId }` is the **only** explicit way to say "I am done with this one" and
|
|
482
|
+
it reclaims immediately (idempotent, `closed: false` when it was not live). A one-shot call
|
|
483
|
+
(`ai "…"`) ends with it, otherwise every invocation would leave a live session behind until
|
|
484
|
+
the grace period expires.
|
|
485
|
+
- **Closing is cheap and lossless**: the archive was written before `session-turn-end`, so a closed
|
|
486
|
+
session is re-hydrated by `session.attach` straight from disk.
|
|
487
|
+
- `session.list` reports `live` (in host memory right now) — a fact, not a client declaration.
|
|
488
|
+
|
|
489
|
+
### The list is one list: `session.list { workspaceId, includeSaved? }`
|
|
490
|
+
|
|
491
|
+
A sidebar needs **all** sessions and, for each, whether it is running right now. Both live on the
|
|
492
|
+
same entry — `live` + `phase` next to the archive fields — so a client never stitches two sources
|
|
493
|
+
(and can never stitch the same session twice):
|
|
494
|
+
|
|
495
|
+
```
|
|
496
|
+
{ sessionId, agentId, title?, modelId?, effectiveModelId?, effectiveModelLevel?, messageCount, updatedAt, live, phase }
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
- **No message bodies.** The store keeps a per-session sidecar (`<id>.meta.json`) with exactly
|
|
500
|
+
these counters, written together with the archive; listing reads the sidecar and never parses
|
|
501
|
+
history. Archives written before the sidecar existed are read once and back-filled.
|
|
502
|
+
- **`includeSaved: false`** = live sessions only (no disk read).
|
|
503
|
+
- **Order** is `updatedAt` descending, ties broken by id descending — two sessions created in
|
|
504
|
+
the same millisecond must not swap places on every refresh.
|
|
505
|
+
- **`updatedAt` is the time of the last *change*, not of the last *look*.** It is stamped where the
|
|
506
|
+
archive is written, so `session.attach` / opening a session from a sidebar is **not** an
|
|
507
|
+
activity: a continued session keeps the archived value, and only an actual archive write moves
|
|
508
|
+
it. (The client reads it from this one field — no second copy anywhere.)
|
|
509
|
+
|
|
510
|
+
### `modelId` vs `effectiveModelId`: two facts, one response
|
|
511
|
+
|
|
512
|
+
`modelId` is the **session-level override** — the innermost level of the three-level resolution,
|
|
513
|
+
i.e. what `session.model.set` wrote into the archive. It is absent for most sessions, because
|
|
514
|
+
most sessions follow their agent definition or the global fallback, and its meaning stays exactly
|
|
515
|
+
that (`session.snapshot`'s `modelId` is something else: that one is the *effective* model).
|
|
516
|
+
|
|
517
|
+
`effectiveModelId` + `effectiveModelLevel` are the **resolved** model and the level it came from
|
|
518
|
+
(`session` > `agent` > `global`, single implementation: the SDK's `resolveModel`). They exist
|
|
519
|
+
because a sidebar's model dropdown has to show the current value: with only the override field,
|
|
520
|
+
every session that follows its definition renders as "no model selected". The fix belongs on the
|
|
521
|
+
host — a client that re-derives the value from "override + definition + default" is a **second
|
|
522
|
+
implementation of the priority order**, and the first symptom is a dropdown showing A while the
|
|
523
|
+
request goes out as B. Render `effectiveModelId`; take the "which level is in effect" badge from
|
|
524
|
+
`effectiveModelLevel` (same vocabulary as `session.snapshot`'s `modelLevel`).
|
|
525
|
+
|
|
526
|
+
Both fields are **omitted** when the host cannot resolve a model for that entry (the agent
|
|
527
|
+
definition was deleted, or all three levels are empty). They are never fabricated and never
|
|
528
|
+
fall back to `modelId` — a guessed model just makes the UI lie more confidently.
|
|
529
|
+
|
|
530
|
+
Cost: live entries read the model the host's own agent was built with (the resolution already
|
|
531
|
+
happened in `createAgent`) — free, and always consistent with `session.snapshot`. Archived
|
|
532
|
+
entries have no agent instance, so they go through the injected `resolveSessionModel`
|
|
533
|
+
(`agentId`'s definition + `AppConfig` + `resolveModel`). That resolver is asked **once per
|
|
534
|
+
`agentId`** (and per distinct session-level override, which is part of its input) within a single
|
|
535
|
+
`list` call — most workspaces keep one or two agents for all their sessions. It is deliberately
|
|
536
|
+
*not* cached across calls: config and definitions change, and a refresh must show the new answer.
|
|
537
|
+
A resolution that throws drops those two fields for that entry only; the rest of the list is
|
|
538
|
+
still returned (an archive can easily outlive the definition it was created under).
|
|
539
|
+
|
|
540
|
+
### `title` vs `id`
|
|
541
|
+
|
|
542
|
+
`id` is the identity (archive filename, `session-id-changed`, the resume pointer all point at it).
|
|
543
|
+
`title` is the name a human reads; renaming never moves the id. `session.rename { workspaceId,
|
|
544
|
+
sessionId, title }` works on archived sessions too (it does not require an `attach` first), and
|
|
545
|
+
`session.saveAs` — "save this conversation under another id" — carries the title along.
|
|
546
|
+
|
|
547
|
+
The same split applies to workspaces (`id` + `title`); `name` is kept for things whose `name`
|
|
548
|
+
*is* the identifier (a skill's frontmatter `name`, an `mcp.json` server key, and the SDK's
|
|
549
|
+
`AgentDefinition` / `Endpoint` / `Model`, which predate this rule).
|
|
550
|
+
|
|
551
|
+
### Automatic titles
|
|
552
|
+
|
|
553
|
+
After the **first** turn of a session settles, the host asks a model for a name and writes it
|
|
554
|
+
into the archive; `session-title-changed` tells clients about it (`session-turn-end` is already out — the
|
|
555
|
+
name never blocks anything). Three limits, each of them deliberate: it only runs when the title
|
|
556
|
+
is still empty (**a hand-renamed title is never overwritten**), it runs at most once per session
|
|
557
|
+
(failure just leaves the title empty — no retries, no error notice), and it is bounded by a 10s
|
|
558
|
+
timeout. The model is `AppConfig.defaultMigrationModel` when configured, otherwise the session's
|
|
559
|
+
effective model. `AppConfig.autoTitle: false` turns the whole thing off (a missing field means on).
|
|
560
|
+
|
|
561
|
+
### `session.remove`
|
|
562
|
+
|
|
563
|
+
Deletes the archive (and its sidecar). Idempotent: `removed: false` when it was not there — same
|
|
564
|
+
idiom as `agent.remove`. A live session is closed first, so a turn still running finishes in the
|
|
565
|
+
background but **never writes the archive back**: what the user deleted must not reappear.
|
|
566
|
+
|
|
567
|
+
### `session.setAgent`: same conversation, different agent
|
|
568
|
+
|
|
569
|
+
Agent identity belongs to the host (the agent runs there), so `/agent` cannot be done by
|
|
570
|
+
re-mounting a screen with a different `agentId` — `attach` returns a live session *as is* and
|
|
571
|
+
ignores that field, which is how "switched!" could be shown while the next turn still ran the
|
|
572
|
+
old agent. `session.setAgent` rebuilds the agent from the new definition, keeps
|
|
573
|
+
`agent.messages`, updates the session's `agentId`, and **writes the session once** (the archive
|
|
574
|
+
records `agentId`; skipping the write makes the next `attach` load the old agent back). It does
|
|
575
|
+
**not** invalidate the Scope: the Scope holds config and capability discovery, which are not
|
|
576
|
+
agent-specific, and invalidating it would evict every other session in the workspace.
|
|
577
|
+
|
|
578
|
+
### Sub-agents: a tool, not a session
|
|
579
|
+
|
|
580
|
+
Delegated sub-agents are *tool calls* (`AgentTool` / `AgentToolLazy`), and the protocol treats
|
|
581
|
+
them that way: `sub-agent-start` / `sub-agent-end` carry a per-turn `index` (the label) and a
|
|
582
|
+
stable `subAgentId` (the client's selection key), the raw `chunk` event grows an optional
|
|
583
|
+
`subAgentId` (so a client folds a sub-agent's transcript from the same stream the main one uses),
|
|
584
|
+
and `session.subAgent.snapshot` returns that sub-agent's own transcript. Two invariants:
|
|
585
|
+
|
|
586
|
+
- **A sub-agent's deltas never move the main phase.** While a sub-agent runs, the main agent is
|
|
587
|
+
waiting for the delegating tool to return; the phase must stay "waiting for tool X".
|
|
588
|
+
- **They only live for the current turn.** The registry is cleared when the turn settles (and on
|
|
589
|
+
`reload` / re-key / close), matching what the client shows: a sub-agent is worth looking at
|
|
590
|
+
while it runs, and its transcript is otherwise history. One exception: the *id* counter is not
|
|
591
|
+
reset, so a client's stale selection can never attach to the next turn's `sub-1`.
|
|
592
|
+
|
|
593
|
+
### The agent's own events go out verbatim: same names, same moments
|
|
594
|
+
|
|
595
|
+
Eight of the fifteen events are core's own (`AgentEventName`) and the host forwards them
|
|
596
|
+
**unchanged**, at the moment core emits them — renaming them on the wire would mean keeping a
|
|
597
|
+
translation table across protocol, host and client, and a table only ever drifts:
|
|
598
|
+
|
|
599
|
+
| Event | Fired by the host when |
|
|
600
|
+
|---|---|
|
|
601
|
+
| `open` | core `open` — the request is about to go out, once per inner loop |
|
|
602
|
+
| `chunk` | core `chunk` — one raw chunk arrived, **before** it is parsed |
|
|
603
|
+
| `parsed` | core `parsed` — the stream ended, `receiver` is final |
|
|
604
|
+
| `tool-call-end` | a `role: "tool"` message reached a finished status (payload: that message) |
|
|
605
|
+
| `error` | core `error` — the turn failed, **before** `finally` |
|
|
606
|
+
| `finally` | core `finally` — core is done with the turn |
|
|
607
|
+
| `sub-agent-start` / `sub-agent-end` | core's delegation boundary |
|
|
608
|
+
|
|
609
|
+
`open` / `parsed` / `error` / `finally` are unused by today's clients and are still sent: they are
|
|
610
|
+
core's public facts, and a client that later wants them (a "started" timestamp, a "core is done"
|
|
611
|
+
edge) must not need a host change. Payloads are minimal by design — `{ sessionId, turnId }`,
|
|
612
|
+
plus `chunk` for the raw piece (vendor shape, the protocol does not interpret it) and a flattened
|
|
613
|
+
`{ code, message }` for `error` (`Error` instances cannot go on the wire).
|
|
614
|
+
|
|
615
|
+
**core's `chunk-parsed` is not forwarded.** It carried the text/reasoning delta of one piece: a
|
|
616
|
+
derived copy of what the raw `chunk` already says, recomputed on the host and re-sent on the
|
|
617
|
+
wire, with its own payload type and its own `subAgentId` routing. Clients fold `chunk` with
|
|
618
|
+
core's `applyChunk` instead — one implementation, on both ends.
|
|
619
|
+
|
|
620
|
+
One timing is part of the contract: a failed turn goes `error` → `finally` → `session-turn-end`.
|
|
621
|
+
**`finally` and `session-turn-end` are two different events**: the former only says core is done,
|
|
622
|
+
the latter goes out after the archive is written, ids are swapped and the reclaim check has run
|
|
623
|
+
(`SessionManager.settleTurn`).
|
|
624
|
+
|
|
625
|
+
### Tool results go out the moment they settle
|
|
626
|
+
|
|
627
|
+
A tool result is the product of *executing* a tool, not of any chunk, so no client can fold it out
|
|
628
|
+
of the stream. It travels on `tool-call-end`:
|
|
629
|
+
|
|
630
|
+
```
|
|
631
|
+
{ event: "tool-call-end", sessionId, turnId, message } // message: core's own Message
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
`message` is forwarded unchanged — the protocol does not interpret its fields, and there is no
|
|
635
|
+
"tool result event" shape to drift from core's message. Core emits `message` at its single append
|
|
636
|
+
point (`Agent.append`) and at the two places it finalises a message in place; the host uses that
|
|
637
|
+
only as the **trigger**, forwarding the one case clients need: a `role: "tool"` message that has
|
|
638
|
+
reached a finished status.
|
|
639
|
+
|
|
640
|
+
Why not wait for the snapshot: `session.snapshot` only exists after `session-turn-end`, and a turn
|
|
641
|
+
can run for minutes — the user would stare at the argument card without ever seeing what the tool
|
|
642
|
+
produced.
|
|
643
|
+
|
|
644
|
+
**Why there is no `message` event any more.** It used to forward every appended/finalised message
|
|
645
|
+
(the user message, the placeholder assistant, tool results) with `message.id` as the upsert key,
|
|
646
|
+
and it had **no subscriber on any client**: the user message is constructed by the client itself,
|
|
647
|
+
the placeholder is client-side folding state, and the tool result already travels on
|
|
648
|
+
`tool-call-end`. A derived copy of an existing fact is a second definition of that fact, and this
|
|
649
|
+
one dragged along an id-based upsert rule, a `subAgentId` variant and a pending→final two-phase
|
|
650
|
+
protocol that nothing on either end read.
|
|
651
|
+
|
|
652
|
+
### Notices: two channels, on one wire
|
|
653
|
+
|
|
654
|
+
Events carry **facts, never sentences**, and notices are no exception: a notice is
|
|
655
|
+
`{ level, code, params? }` — the client looks up `notice.<code>` in its own i18n table.
|
|
656
|
+
Codes are protocol (`SessionNoticeCode` / `HostNoticeCode`): add new ones, never
|
|
657
|
+
repurpose an existing one.
|
|
658
|
+
|
|
659
|
+
The channel is the **namespace**, not a field:
|
|
660
|
+
|
|
661
|
+
| Event | Bound to | Rendered where |
|
|
662
|
+
|---|---|---|
|
|
663
|
+
| `session-notice` | a session (`sessionId` is required) | inside the conversation — disappears when you switch away |
|
|
664
|
+
| `host-notice` | nothing | app-level (status bar / banner) — must survive session switches |
|
|
665
|
+
|
|
666
|
+
Both go through the same `EventHub`, so a client needs **one** subscription. A
|
|
667
|
+
`workspace.notice` is the natural third if a notice ever belongs to a workspace but to no
|
|
668
|
+
particular session — adding it does not change these two.
|
|
669
|
+
|
|
670
|
+
Two design points worth keeping:
|
|
671
|
+
|
|
672
|
+
- `session.send` returns `{ turnId }` **immediately**; everything else arrives as events.
|
|
673
|
+
A turn may run for minutes — a request/response `send` would leave the client with nothing
|
|
674
|
+
to render and no sane timeout.
|
|
675
|
+
- Events carry **facts, never sentences** (`{ kind, tools }`, not "等待工具返回…"), so one
|
|
676
|
+
host can serve a Chinese terminal and an English browser at the same time.
|
|
677
|
+
|
|
678
|
+
### Handoff: two methods, one round each
|
|
679
|
+
|
|
680
|
+
`session.handoff` writes a handoff document; `session.migrate` writes it **and opens a new
|
|
681
|
+
session**. The flow itself (stop → read the `handoff` skill → write → validate → degrade
|
|
682
|
+
`self` → `summary` → prune history → inject the breakpoint) lives in the SDK; this package
|
|
683
|
+
only adds the host-side effects: documents under `<workspace>/.ai-zen/handoffs/`,
|
|
684
|
+
notices instead of sentences, and the session-id switch.
|
|
685
|
+
|
|
686
|
+
Three things are worth knowing before touching it:
|
|
687
|
+
|
|
688
|
+
- **`migrate` has a `strategy`, and it decides identity.** `prune` (the default) physically drops
|
|
689
|
+
the history, so the old segment only survives as an
|
|
690
|
+
archive: the old id is archived first (before pruning), the handoff round is settled **on
|
|
691
|
+
the old id**, and only then does the live session move to a new key. `omit` marks the old
|
|
692
|
+
messages `omit: true` and keeps them, so nothing about the session changed — it **keeps its
|
|
693
|
+
id** (no `idChanged`), `turnId` keeps counting, and the archive stays one file (written on
|
|
694
|
+
settle). The two are not separate knobs: pruning means "these are two conversations",
|
|
695
|
+
omitting means "same conversation, older messages are no longer sent".
|
|
696
|
+
- **Both are one round.** They bump `turnId` like `send` does, so phase/delta events belong
|
|
697
|
+
to a real turn; the settling is owned by the method's promise (`settleDeferred`), because
|
|
698
|
+
the SDK also emits `finally` for the handoff round itself.
|
|
699
|
+
- **Auto-migration mutates a turn that is already running.** When the model declares
|
|
700
|
+
`tokenThresholds`, the factory installs `ContextGuardPlugin` (hard line, suspended while a
|
|
701
|
+
handoff is in flight) and `AutoMigratePlugin` (soft line). The same `thresholds.guard` also
|
|
702
|
+
feeds `SubAgentGuardInstallerPlugin`, which re-installs the guard on every delegated
|
|
703
|
+
sub-agent from `onSubAgentStart` — a sub-agent is a fresh `new Agent()` that inherits no
|
|
704
|
+
plugins and runs through `run()` (so `onBeforeSend` / `onAfterSend` never fire for it), and
|
|
705
|
+
it recursively installs itself so grandchildren are covered too. The soft line fires *inside*
|
|
706
|
+
`send()`, so it must not open a new turn and must not settle the outer one; it archives,
|
|
707
|
+
prunes, re-keys and then emits `session-id-changed` — that event is the only way a client
|
|
708
|
+
learns about a new id when no request is in flight.
|
|
709
|
+
- **Nothing is archived by hooks.** `HandoffService` runs its hooks through `safeHook`,
|
|
710
|
+
which logs and continues — a failed archive inside a hook would let the prune destroy the
|
|
711
|
+
history anyway. Archive / prune / adopt are three ordered statements in
|
|
712
|
+
`SessionManager.migrate()`, each free to fail loudly.
|
|
713
|
+
- **Progress is notices, not sentences.** `handoffWriting` / `handoffAutoDetected` /
|
|
714
|
+
`migrationDegraded` / `handoffPath` / `handoffArchived` / `handoffMigrated` +
|
|
715
|
+
`handoffMigratedHint` / `migrationSkipped` — all `SessionNoticeCode`, so each one is a code a
|
|
716
|
+
client renders with its own wording (the host emits no prose).
|
|
717
|
+
|
|
718
|
+
## Config and agent definitions
|
|
719
|
+
|
|
720
|
+
`src/protocol/config.ts` is the contract for everything that changes **global** state and
|
|
721
|
+
belongs to no session: the config centre (`config.get` / `config.save` /
|
|
722
|
+
`config.reasoningPresets`, `config.mcp.get` / `config.mcp.save` scoped `global` | `project`)
|
|
723
|
+
and agent definitions (`agent.list` / `get` / `save` / `remove`, next to the narrower
|
|
724
|
+
`agent.model.set`). `ConfigService` wraps the SDK's `ConfigManager` / `AgentRepository`; every
|
|
725
|
+
path (`config.json`, `agents/`, `sub-agents/`, `mcp.json`, workspaces root) is injected by the
|
|
726
|
+
composition root.
|
|
727
|
+
|
|
728
|
+
Why this layer exists at all: config must be read and written by the **side that owns it**.
|
|
729
|
+
If the client wrote its own disk, both ends on one machine would look fine and fail silently —
|
|
730
|
+
the UI reports success while the host's agents keep using the old model. Three deliberate choices:
|
|
731
|
+
|
|
732
|
+
- **Payloads are `unknown`.** The shape of `AppConfig` / `AgentDefinition` belongs to the SDK;
|
|
733
|
+
a browser client should not have to install it to read a config. `unknown` becomes a real
|
|
734
|
+
type at exactly one boundary — the handler — so the assertions live in one auditable place.
|
|
735
|
+
- **`config.save` is a whole-document overwrite.** No `syncManagedModels` on the write path:
|
|
736
|
+
that is `bootstrap()`'s job, and re-syncing inside a save would silently revert the models
|
|
737
|
+
the user just edited.
|
|
738
|
+
- **`agent.get` takes an optional `kind`.** The same id in `agents/` and `sub-agents/` is
|
|
739
|
+
normal (both are called `default`), so a two-pane UI must say which pool it means; without
|
|
740
|
+
it the lookup is top-level first, then sub-agent.
|
|
741
|
+
|
|
742
|
+
## Two hard constraints (machine-checkable)
|
|
743
|
+
|
|
744
|
+
1. **No UI stack here.** The package boundary is the process boundary — `ink`, `react` and
|
|
745
|
+
terminal helpers belong to the client. `dependencies` holds `@ai-zen/agents-core` and
|
|
746
|
+
`@ai-zen/agents-sdk` — no UI stack, no client.
|
|
747
|
+
2. **No process-wide globals.** No current working directory, no global config paths, no
|
|
748
|
+
global locale: everything comes in through constructor arguments. The single exception is
|
|
749
|
+
`src/cli.ts` — the composition root — which is *allowed* to read `AI_ZEN_DIR` / `HOME` /
|
|
750
|
+
`argv` and must immediately turn them into injected values.
|
|
751
|
+
|
|
752
|
+
```bash
|
|
753
|
+
# 行首是 ` *` 的是**注释行**(`Daemon.ts` 的文件头用散文说了 `AI_ZEN_DIR`),`*.test.ts` 里的只
|
|
754
|
+
# 是用例名字 —— 门禁只该扫代码行,修的是命令而不是注释
|
|
755
|
+
grep -rn "process\.cwd()\|AI_ZEN_DIR\|refreshPaths\|setLocale\|getScope" packages/host/src \
|
|
756
|
+
| grep -v "src/cli.ts" | grep -v '\.test\.' | grep -v '^\S*:[0-9]*: *\*' # must be empty
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
That grep *is* the definition of "locally instantiable": any module here can be built in a
|
|
760
|
+
test with two or three fakes and nothing else.
|