@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +759 -2
  4. package/dist/app.d.ts +384 -0
  5. package/dist/app.js +1148 -0
  6. package/dist/cli.d.ts +21 -0
  7. package/dist/cli.js +353 -0
  8. package/dist/config.d.ts +97 -0
  9. package/dist/config.js +171 -0
  10. package/dist/handlers/agentHandlers.d.ts +18 -0
  11. package/dist/handlers/agentHandlers.js +20 -0
  12. package/dist/handlers/assetHandlers.d.ts +29 -0
  13. package/dist/handlers/assetHandlers.js +35 -0
  14. package/dist/handlers/capabilities.d.ts +26 -0
  15. package/dist/handlers/capabilities.js +138 -0
  16. package/dist/handlers/completionHandlers.d.ts +18 -0
  17. package/dist/handlers/completionHandlers.js +13 -0
  18. package/dist/handlers/configHandlers.d.ts +31 -0
  19. package/dist/handlers/configHandlers.js +59 -0
  20. package/dist/handlers/conversationHandlers.d.ts +118 -0
  21. package/dist/handlers/conversationHandlers.js +314 -0
  22. package/dist/handlers/handoffHandlers.d.ts +17 -0
  23. package/dist/handlers/handoffHandlers.js +14 -0
  24. package/dist/handlers/hostHandlers.d.ts +39 -0
  25. package/dist/handlers/hostHandlers.js +56 -0
  26. package/dist/handlers/index.d.ts +128 -0
  27. package/dist/handlers/index.js +92 -0
  28. package/dist/handlers/messageHandlers.d.ts +11 -0
  29. package/dist/handlers/messageHandlers.js +10 -0
  30. package/dist/handlers/modelHandlers.d.ts +17 -0
  31. package/dist/handlers/modelHandlers.js +19 -0
  32. package/dist/handlers/noticeHandlers.d.ts +24 -0
  33. package/dist/handlers/noticeHandlers.js +24 -0
  34. package/dist/handlers/questionHandlers.d.ts +16 -0
  35. package/dist/handlers/questionHandlers.js +16 -0
  36. package/dist/handlers/searchHandlers.d.ts +12 -0
  37. package/dist/handlers/searchHandlers.js +12 -0
  38. package/dist/handlers/workspaceHandlers.d.ts +29 -0
  39. package/dist/handlers/workspaceHandlers.js +47 -0
  40. package/dist/i18n/en.d.ts +243 -0
  41. package/dist/i18n/en.js +270 -0
  42. package/dist/i18n/index.d.ts +43 -0
  43. package/dist/i18n/index.js +72 -0
  44. package/dist/i18n/zh.d.ts +240 -0
  45. package/dist/i18n/zh.js +257 -0
  46. package/dist/index.d.ts +17 -0
  47. package/dist/index.js +15 -0
  48. package/dist/net/UploadReceiver.d.ts +186 -0
  49. package/dist/net/UploadReceiver.js +494 -0
  50. package/dist/net/WsHub.d.ts +231 -0
  51. package/dist/net/WsHub.js +689 -0
  52. package/dist/net/auth.d.ts +73 -0
  53. package/dist/net/auth.js +74 -0
  54. package/dist/net/discovery.d.ts +72 -0
  55. package/dist/net/discovery.js +171 -0
  56. package/dist/net/index.d.ts +14 -0
  57. package/dist/net/index.js +10 -0
  58. package/dist/runtime/ChatRunner.d.ts +374 -0
  59. package/dist/runtime/ChatRunner.js +1204 -0
  60. package/dist/runtime/ConversationDrafts.d.ts +43 -0
  61. package/dist/runtime/ConversationDrafts.js +47 -0
  62. package/dist/runtime/RunRegistry.d.ts +79 -0
  63. package/dist/runtime/RunRegistry.js +108 -0
  64. package/dist/runtime/ScopeRegistry.d.ts +153 -0
  65. package/dist/runtime/ScopeRegistry.js +259 -0
  66. package/dist/runtime/TransientRunner.d.ts +90 -0
  67. package/dist/runtime/TransientRunner.js +179 -0
  68. package/dist/runtime/index.d.ts +14 -0
  69. package/dist/runtime/index.js +10 -0
  70. package/dist/services/AgentService.d.ts +150 -0
  71. package/dist/services/AgentService.js +718 -0
  72. package/dist/services/AssetService.d.ts +338 -0
  73. package/dist/services/AssetService.js +739 -0
  74. package/dist/services/AutoNaming.d.ts +92 -0
  75. package/dist/services/AutoNaming.js +235 -0
  76. package/dist/services/CompletionService.d.ts +122 -0
  77. package/dist/services/CompletionService.js +442 -0
  78. package/dist/services/ConfigService.d.ts +171 -0
  79. package/dist/services/ConfigService.js +695 -0
  80. package/dist/services/ConversationService.d.ts +274 -0
  81. package/dist/services/ConversationService.js +902 -0
  82. package/dist/services/CredentialService.d.ts +33 -0
  83. package/dist/services/CredentialService.js +86 -0
  84. package/dist/services/DiscoveryService.d.ts +182 -0
  85. package/dist/services/DiscoveryService.js +998 -0
  86. package/dist/services/HandoffService.d.ts +189 -0
  87. package/dist/services/HandoffService.js +414 -0
  88. package/dist/services/Logger.d.ts +130 -0
  89. package/dist/services/Logger.js +256 -0
  90. package/dist/services/MessageService.d.ts +88 -0
  91. package/dist/services/MessageService.js +280 -0
  92. package/dist/services/ModelService.d.ts +89 -0
  93. package/dist/services/ModelService.js +489 -0
  94. package/dist/services/NoticeService.d.ts +93 -0
  95. package/dist/services/NoticeService.js +217 -0
  96. package/dist/services/QuestionService.d.ts +184 -0
  97. package/dist/services/QuestionService.js +457 -0
  98. package/dist/services/SearchService.d.ts +54 -0
  99. package/dist/services/SearchService.js +117 -0
  100. package/dist/services/WorkspaceService.d.ts +96 -0
  101. package/dist/services/WorkspaceService.js +213 -0
  102. package/dist/services/imageSize.d.ts +41 -0
  103. package/dist/services/imageSize.js +146 -0
  104. package/dist/services/index.d.ts +20 -0
  105. package/dist/services/index.js +15 -0
  106. package/dist/services/secretMask.d.ts +24 -0
  107. package/dist/services/secretMask.js +34 -0
  108. package/dist/storage/AssetRepository.d.ts +87 -0
  109. package/dist/storage/AssetRepository.js +180 -0
  110. package/dist/storage/ConversationRepository.d.ts +55 -0
  111. package/dist/storage/ConversationRepository.js +128 -0
  112. package/dist/storage/CredentialRepository.d.ts +28 -0
  113. package/dist/storage/CredentialRepository.js +47 -0
  114. package/dist/storage/Database.d.ts +95 -0
  115. package/dist/storage/Database.js +180 -0
  116. package/dist/storage/MessageRepository.d.ts +104 -0
  117. package/dist/storage/MessageRepository.js +323 -0
  118. package/dist/storage/MetaRepository.d.ts +21 -0
  119. package/dist/storage/MetaRepository.js +33 -0
  120. package/dist/storage/NoticeRepository.d.ts +31 -0
  121. package/dist/storage/NoticeRepository.js +89 -0
  122. package/dist/storage/QuestionRepository.d.ts +34 -0
  123. package/dist/storage/QuestionRepository.js +96 -0
  124. package/dist/storage/WorkspaceRepository.d.ts +25 -0
  125. package/dist/storage/WorkspaceRepository.js +44 -0
  126. package/dist/storage/dbWorker.d.ts +10 -0
  127. package/dist/storage/dbWorker.js +132 -0
  128. package/dist/storage/index.d.ts +20 -0
  129. package/dist/storage/index.js +16 -0
  130. package/dist/storage/migrations.d.ts +26 -0
  131. package/dist/storage/migrations.js +207 -0
  132. package/dist/storage/types.d.ts +298 -0
  133. package/dist/storage/types.js +14 -0
  134. package/dist/wire/errors.d.ts +105 -0
  135. package/dist/wire/errors.js +143 -0
  136. package/dist/wire/events.d.ts +102 -0
  137. package/dist/wire/events.js +13 -0
  138. package/dist/wire/frames.d.ts +106 -0
  139. package/dist/wire/frames.js +156 -0
  140. package/dist/wire/index.d.ts +31 -0
  141. package/dist/wire/index.js +31 -0
  142. package/dist/wire/methods.d.ts +1896 -0
  143. package/dist/wire/methods.js +99 -0
  144. package/dist/wire/states.d.ts +487 -0
  145. package/dist/wire/states.js +61 -0
  146. package/package.json +63 -3
package/README.md CHANGED
@@ -1,3 +1,760 @@
1
- # Temporary Holding Version
1
+ # @ai-zen/agents-host
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.