akanjs 3.0.0 → 3.0.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 CHANGED
@@ -1,5 +1,3664 @@
1
1
  # akanjs
2
2
 
3
+ ## 3.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ccf2ae2: A CSR dev page — `?csr=true`, `/__csr`, and every native dev build — updates in place on save instead of reloading.
8
+
9
+ - `akan start` serves the CSR page as a module registry: each module is its own factory, and a save sends only the
10
+ modules it changed. A component edit keeps hook and DOM state (React Fast Refresh), a store edit keeps the store,
11
+ and a build error shows the overlay and recovers without a reload.
12
+ - A page or layout edit swaps that route in place through `replacePages`: the page stack, history and stores stay,
13
+ and so does the state of the components under the edited page or layout. A `*.constant.ts`, an added or removed
14
+ route, a new npm dependency or a signal/dictionary change still reloads.
15
+ - `AKAN_DEV_CSR=artifact` brings back the single-file dev bundle, which reloads on every save. `akan build` and a
16
+ release mobile build are unchanged.
17
+ - HMR sockets now say what they are: a CSR page connects as `/_akan/hmr?client=csr` and gets `csr-update` instead of
18
+ the SSR `reload` and `rsc-refresh`, and a page that missed an update while disconnected reloads
19
+ when it reconnects.
20
+
21
+ - ccf2ae2: A Windows desktop app can share its screen without the picker
22
+
23
+ `native.desktop.screenCapture: "auto"` in `akan.config.ts`, or `desktop.screenCapture` on one target, makes the
24
+ Windows app answer the page's `getDisplayMedia()` with the first screen at once, without the picker and without a user
25
+ gesture, for remote support on a screen nobody attends. It is Chromium's switch for automated media tests and covers
26
+ every media request, so leave it off in an app whose pages ask for a camera or a microphone. The default `"picker"`
27
+ keeps the picker; macOS and Linux ignore it.
28
+
29
+ - ccf2ae2: A page a native shell serves reaches its API, and keeps its session, without the cookies it no longer has.
30
+
31
+ The shell serves the page from `app://localhost` (iOS, macOS, Linux) or `https://app.localhost` (Android, Windows),
32
+ which is cross-origin to the API and names neither its host nor its port, and WKWebView keeps no cookies for it.
33
+
34
+ - `CrossSiteGuard` admits those two origins instead of `capacitor://localhost`, `ionic://localhost` and
35
+ `http://localhost`, and every signal route answers `OPTIONS` for an allowlisted origin with the verbs it serves,
36
+ so a bearer header or a JSON body preflights without an ingress in front. Never with
37
+ `access-control-allow-credentials`: the `SameSite=None` cookie would let an allowed origin ride a browser session.
38
+ - `AKAN_PUBLIC_SERVER_URL` (http or https) names a CSR bundle's server — host, port and protocol, the websocket
39
+ URL following it. A cloud CSR bundle without it calls its cloud host on 443; SSR tabs and servers ignore it.
40
+ - A CSR client's session is the bearer token `fetch` sends: `getAuthToken()` reads it before the cookie jar and
41
+ `setAuth()` no longer copies it into a cookie. An `app://` page keeps its other cookies (theme,
42
+ `prepareUserId`, …) in a `localStorage` jar. The `CapacitorCookies` mirror is gone.
43
+ - `libs/shared`: a CSR client keeps each scope's refresh token (user, admin) in storage and sends it in the body,
44
+ which the server already accepted; sign-in, activation and refresh keep the rotated token, sign-out drops it, and one
45
+ refresh per scope runs at a time, since a second use of a rotated token reads as theft and ends every session.
46
+
47
+ **Breaking for a Capacitor build still in the field:** its `capacitor://localhost` origin is refused.
48
+
49
+ - ccf2ae2: A native app can sign in through the system browser and hear the callback on every desktop OS.
50
+
51
+ - `akanjs/client/native` exports `authSession` and its `AuthSessionApi` type, the runtime's system-browser sign-in,
52
+ and `isNativeShell()`, true in any native shell; `isNativeApp()` stays iOS and Android only.
53
+ - `getServerOrigin()` in `akanjs/base` is the server's origin as a browser outside the page opens it. For a page the
54
+ native dev gateway served, that is the dev server (`http://localhost:<dev port>`), not the app origin the page
55
+ calls; otherwise it is the origin of `getEnv().serverHttpUri`.
56
+ - A native dev build on Android reverses the dev server's port as well as the gateway's, so a browser on the device
57
+ reaches it.
58
+ - A target with `deepLinks.schemes` ships `single-instance`. Windows and Linux open a link by starting the app again,
59
+ and the hand-over is how the link reaches the running app. A second launch of such an app now hands over and exits
60
+ instead of opening a second window, and a desktop debug build keeps the release app id, so it does the same while
61
+ the release app runs.
62
+ - In an app with basePaths whose only target names none, `start-*` and `build-*` without `--target` ask for a basePath
63
+ instead of opening the root, which has no CSR page.
64
+
65
+ - ccf2ae2: A push for the page on screen is not shown in front, and `usePurchase` verifies with the app's own function.
66
+
67
+ - The push plugin's `setForegroundPresentation` takes `except: { key, values }`: a push whose `data[key]` is one of
68
+ `values` is not shown while the app is in front, and still arrives as `received`. The CSR frame keeps it set to
69
+ the page on screen, so the chat room being read does not banner its own messages; `data.url` may spell the page
70
+ with or without the locale and the target's basePath.
71
+ - `usePurchase({ verify })` checks a purchase with the app's function instead of `POST <url>/billing/verifyBilling`.
72
+ It gets the normalized proof and the transaction, whose `verification` carries the store's own; what it resolves
73
+ reaches `onPay`/`onSubscribe`, and `null`, `undefined` or `false` refuses. The finish flow is unchanged.
74
+
75
+ - ccf2ae2: The app's websocket refuses a page from another site
76
+
77
+ A socket has no CORS, so a page on any site could open `/api/ws` on a server it reached — a desktop app's loopback
78
+ server through the user's own browser — and read every room it may subscribe to, riding the `SameSite=None` auth
79
+ cookie where there is one. The upgrade now runs `CrossSiteGuard`'s origin check, the one mutations and the dev HMR
80
+ socket already pass: a same-site page, the native shells (`app://localhost`, `https://app.localhost`), an origin in
81
+ `allowedOrigins` and a caller that sends no `Origin` connect as before, and any other origin is refused with 403.
82
+ `option.setCrossSite({ enabled: false })` turns it off with the rest of the gate.
83
+
84
+ **Breaking for a web client on another site that only read and subscribed:** its socket is refused until its origin is
85
+ in `allowedOrigins`, as its mutations already needed.
86
+
87
+ - 8c31d54: The in-page chat guards the model's window, and answers a refusal for length by compacting once
88
+
89
+ Compaction used to watch one number: the transcript, estimated at four characters a token, against `compact.at`
90
+ (24k). It did not know the window, did not count the tools, the screen context or the instructions that ride every
91
+ turn, and read Hangul — nearer one character a token than four — as a fraction of what it costs. Measured on a
92
+ single live DeepSeek turn: the transcript estimate said 23 tokens where the provider counted 458. And when the
93
+ provider refused a prompt too long for its window, the turn simply failed.
94
+
95
+ **The relay now reports what it knows.** Every turn's `done` carries the provider's own count (`usage: { input,
96
+ output }`, `input` the whole prompt) and what the adaptor knows about its model (`limits: { window, output }`).
97
+ The window is declared with `option.setLlm({ contextWindow })` — a table of models would be a claim about models
98
+ that ship after it. `LlmAdaptor` gains an optional `limits`; `AnthropicLlm` reports the answer ceiling it always
99
+ sends, `OpenaiLlm` sends none and so reports none.
100
+
101
+ **The session compacts on whichever trigger comes first.** `compact.at` stays, with its 24k default, as a ceiling
102
+ on what each turn costs — the relay resends the whole transcript every turn and the app pays for each one. Beside
103
+ it, once the window is known, a guard compacts when the prompt passes `window − answer ceiling (8,192 when
104
+ unreported) − compact.buffer (13,000)`, measured by the provider's count of the last turn plus the estimate for
105
+ what arrived since. `{ at: Infinity }` leaves only the guard; `{ at: 0 }` still turns all of it off. The count is
106
+ kept on the assistant message (`ChatMessage.usage`), never sent, and dropped from what a compaction keeps.
107
+
108
+ **A refusal for length is answered once.** Adaptors build refusals through `LlmOverflow.refusal(host, status,
109
+ reason)`, which reads each provider's own "prompt too long" sentence as `agent.error.contextOverflow` with the
110
+ `limit` it named. The relay flags it `overflow` on the wire; the session drops the empty draft, compacts, sends the
111
+ same turn again and remembers the window. A second refusal in the same send fails with the translated message.
112
+ An app's own adaptor that throws through `LlmOverflow.refusal` recovers the same way.
113
+
114
+ The chat header now reads `~31k / 107k tokens` — the estimate against whichever trigger is nearer — with the point
115
+ it compacts at in its tooltip.
116
+
117
+ ## Upgrading
118
+
119
+ Nothing is required. To turn the guard on, declare the window your model has:
120
+
121
+ ```ts
122
+ option.setLlm({
123
+ apiKey,
124
+ model: "deepseek-flash",
125
+ host: "https://api.deepseek.com",
126
+ contextWindow: 1_000_000,
127
+ });
128
+ ```
129
+
130
+ A backend of your own that speaks the turn wire (use-agentic `WIRE.md`) opts in by adding `usage` and `limits` to
131
+ its `done` event and `overflow: { limit? }` to a refusal for length; one that sends neither keeps working as before.
132
+ The usage, limits and overflow fields ride the streaming answer — which is what `httpRunner` negotiates — and are
133
+ not added to the relay's single-JSON `AgentTurn` answer.
134
+
135
+ - 5f53462: A message carries the data the user pointed at
136
+
137
+ An in-page agent could be handed files but not data. What was on the screen reached the model only as the turn's
138
+ `ContextBlock`s, which are reassembled from the screen every turn — so a record named three turns ago is not in the
139
+ conversation any more, and there was nowhere to say _which_ record, or which field of it, somebody meant.
140
+
141
+ `ChatMessage.references` is that place, beside `attachments` and for the same reason: what a person referred to
142
+ while asking is part of the asking. A `MessageReference` names the host's own `refName`, the id, a label, and
143
+ optionally a dotted `path` into the document, and carries the `value` those resolved to.
144
+
145
+ Three properties are load-bearing and deliberate:
146
+
147
+ - **The value is a snapshot.** It is what the data was when the message was sent, never re-read later. Re-reading
148
+ would rewrite what the person was looking at when they spoke — and the common case is an agent that then edits
149
+ the very field it was pointed at, which would leave the reference showing the result with no record of what was
150
+ being changed from. `refName`/`refId`/`path` travel so a tool can read the current value when the answer needs it.
151
+ - **The value arrives masked, and nothing downstream can mask it again.** Masking needs the model class, which no
152
+ wire carries, so whichever model the host names when it stages a reference is the whole of the decision about
153
+ what leaves the browser.
154
+ - **It is bounded at 20,000 characters per reference, at both ends.** A reference is bulkier than a tool result and
155
+ outlives one: it rides every turn from the moment it is sent and is the last thing compaction folds, because
156
+ folding what the user pointed at is folding the question. `Reference.clipped` bounds it as it is staged, and
157
+ `AgentService.referenceLimit` bounds it again where nothing can route around it.
158
+
159
+ `AgentSession` holds the staging slot — `stage` / `staged` / `unstage(key)` / `clearStaged()` — rather than the
160
+ composer, which is where staged files live. The difference is not an inconsistency: a file only ever arrives from
161
+ the composer's own picker or drop zone, while a reference arrives from whichever component drew the data, reaching
162
+ the session it is already inside. Unstaging is keyed on `refName/refId#path`, never on an index, because what
163
+ orders the references of a message is its text.
164
+
165
+ The server folds them into the message text in one place (`AgentService.referenced`), under a heading that says the
166
+ values are a snapshot. Text is the one field every provider mapping already reads, so Anthropic, the OpenAI dialect
167
+ and the text-only default all carry references with no change between them and none of them can drop one quietly.
168
+ A string value prints as itself rather than as escaped JSON, which is the common case and the readable one.
169
+
170
+ Persistence keeps the pointer and drops the value, with a note saying so — a restored conversation re-reads what
171
+ was pointed at with a tool instead of guessing from the label, which is a better answer than a restored attachment
172
+ can give.
173
+
174
+ - 5f53462: The composer can point at data, from the menu or from the screen
175
+
176
+ Builds the user-facing half of message references on top of the carrier. There are two entry points, and which one
177
+ applies depends on who knows the value.
178
+
179
+ `<Agent.Chat reference={[…]} />` takes a `ReferenceSource` per kind of document — `refName`, a `type` in
180
+ `st.expose`'s vocabulary, the app's own `search`, and a `resolve` — and the composer's `@` menu offers whole
181
+ documents from them. Which documents somebody may point at is the app's answer, so the source brings the query; the
182
+ framework brings the token, the masking and the snapshot. The token is written the moment a row is picked and the
183
+ value is staged when `resolve` lands, so a slow fetch never freezes the menu and one that fails leaves a pointer
184
+ rather than a chip that means nothing.
185
+
186
+ `useAgentReference()` is the other half, for a field _inside_ a document. The component drawing it already holds
187
+ the value, so it hands that over with no round trip — and it is the only thing that knows a rich-text field stored
188
+ as `field(Any)` reads as a paragraph rather than as the editor document it is stored as. It no-ops with a warning
189
+ outside a session, the call `AgentValue.publishable` already makes: a card carrying a reference button must not
190
+ cost a route its render.
191
+
192
+ **The token in the draft is what carries a reference; the chip only draws it.** Deleting the token by hand drops
193
+ the reference exactly as removing the chip does, because removing the chip removes the token. Nothing re-derives a
194
+ value from edited text, so a token pasted out of an earlier message travels as a pointer with a note — the same
195
+ shape a restored conversation produces.
196
+
197
+ Staging lives on the session rather than the composer, because `useAgentReference` is called from components the
198
+ composer cannot see. Writing the token is still the composer's, so the session holds the unwritten ones as state
199
+ and a chat acknowledges each by key: two chats on one session — a responsive app rendering a desktop and a mobile
200
+ composer — each hold their own draft and each need the token, which a queue somebody drains would have given to
201
+ whichever rendered first. `refer` warns instead of staging silently when no chat is mounted on that session, which
202
+ is the case where a card sits outside the `<Agent.Zone>` its chat is inside.
203
+
204
+ References also ride the parking queue, so one pointed at while a turn is running survives to the message it
205
+ opens; taking a parked message back restores its values, and anything pointed at since wins, because the parked
206
+ value is the older read of the same field.
207
+
208
+ - 5f53462: A chat's transcript has a turn in it
209
+
210
+ `AgentSteps` is a new `_overrides.tsx` slot holding one agent turn: the messages between the user message that
211
+ opened it and the next one, plus `isRunning` for whether more are still coming. It is the grain a chat needs to
212
+ fold a turn's steps into a `details` and stand its final answer outside them — and the one boundary no per-message
213
+ slot can see, because neither message on either side of it knows it is at an edge.
214
+
215
+ The transcript now emits per turn rather than per message. A user message still stands alone as its own `Bubble`;
216
+ everything after it goes to one `AgentSteps` until the next user message, which also means a transcript whose head
217
+ is a compaction summary opens a turn of its own. The array handed over is what the turn _renders_ rather than what
218
+ it holds on the wire: a tool message whose results a call row already draws is gone, and one holding results no
219
+ call claims is narrowed to those, so an app folding a turn never re-derives the call/result pairing.
220
+
221
+ **The default adds nothing.** It draws the same flat bubbles into a Fragment, not a box, so it takes no
222
+ `className` and no existing layout can tell the component is there. `DefaultSteps` is exported from `akanjs/ui`
223
+ beside the other defaults, for a skin that wants to compose the one it is replacing.
224
+
225
+ `isRunning` is only ever true of the last turn of a transcript the session is working on. Without it the same
226
+ messages read the same whether the agent is mid-step or finished, and a scaffold cannot tell a live progress line
227
+ from a completed turn's header.
228
+
229
+ - ccf2ae2: `akan build-desktop --installer` makes a Windows setup program
230
+
231
+ On Windows the build adds `<file>-<version>-<arch>-setup.exe`, made with NSIS (`winget install NSIS.NSIS`). It installs
232
+ for the current user under `%LOCALAPPDATA%\Programs\<app name>`, where the updates plugin swaps the app without an
233
+ administrator, with a Start menu shortcut and an uninstall entry. `/S` installs silently and `/RUN` starts the app
234
+ afterwards, and a PC without the WebView2 Runtime gets it. The program is not code-signed yet.
235
+
236
+ - What can fail without touching the installed app comes first: WebView2, then the new files, unpacked beside the
237
+ folder. Only then is a copy of the app running from the folder stopped, found by its path, and the folder swapped by
238
+ two renames. A swap that fails puts the old folder back, and with `/RUN` the app left in place starts again.
239
+
240
+ - The uninstaller lives beside the app folder (`<folder>.uninstall.exe`), not in the folder an update replaces, so
241
+ Settings › Apps can still remove an updated app. It finds the folder from the uninstall entry and removes it with
242
+ whatever updates left beside it (`.previous`, unpacked or failed releases) and itself, the launch-at-login entry and
243
+ the updates state; the server's data stays. An uninstaller that cannot be
244
+ written there (a `/D=` right under a drive root) fails the install.
245
+ - The Start menu shortcut and the app `/RUN` or the last page starts work in `%LOCALAPPDATA%`: Windows cannot rename a
246
+ folder some process works in, which is what applying an update does.
247
+ - `/RUN` starts the app after a silent install only; an interactive one offers it on its last page.
248
+ - Run again without `/D=`, it installs into the folder the uninstall entry names, so a reinstall replaces the copy
249
+ already installed wherever it was put, and a setup started while another one runs refuses to start.
250
+ - A silent install that cannot finish exits with 2: files it could not write (the script is long-path aware, and the
251
+ build warns when the deepest file nears 260 characters under `%LOCALAPPDATA%\Programs`), a WebView2 Runtime still
252
+ missing after its setup ran (an interactive install asks), or a `/D=` folder holding someone else's files.
253
+ - An update the app confirms sets the version Settings › Apps shows.
254
+
255
+ - ccf2ae2: `akan.config.ts` reaches the native settings a store release needs, and an app keeps a different id on each store.
256
+
257
+ - `native.android.push` (channel, `smallIcon` relative to the app folder, `color`) and `native.ios.privacy` (the iOS
258
+ privacy manifest) pass through, per target too.
259
+ - `native.icon` takes `{ image, backgroundColor }` and `native.splash` the runtime's `{ image, backgroundColor,
260
+ autoHide, timeout }`, besides a path.
261
+ - `appId` is a string or one per platform — `{ ios, android, default }` — for an app whose listings already carry
262
+ different ids. The app-link files serve each platform's own id.
263
+
264
+ - ccf2ae2: An app and a lib ship native plugins of their own from a `native/` folder
265
+
266
+ A plugin the runtime has no builtin for sits in `apps/<app>/native/<id>/` (or `libs/<lib>/native/<id>/`), the folder
267
+ named after the id in its `native-plugin.json`. It is listed nowhere: every mobile and desktop target of the app
268
+ ships it, and its manifest says what runs on each platform. A lib's plugins reach only the apps that depend on it; an
269
+ app's own plugin wins an id a lib also uses, and two libs claiming one id stop the build. `akan sync`, `akan doctor`
270
+ and `akan quality scan` accept the folder.
271
+
272
+ The page API is written with `definePlugin` from `akanjs/client/native` (which also exports `defineWebPlugin`,
273
+ `createLiveValue`, `useLiveValue` and `usePluginEvent`), and the desktop part with `defineDesktopPlugin` from the new
274
+ `akanjs/native/desktop`. A `native/` folder is out of the scope of `no-throw-raw-error` (a plugin throws
275
+ `AkanNativeError`) and of `no-web-only-api-outside-webkit`. A plugin a target names by folder in `native.plugins` is now
276
+ granted by its id instead of its path, and ships once when it is also the app's own `native/<id>`. A desktop plugin's
277
+ `ctx.launch.exit(code)` after its setup ran past the launch phase's 3 s quits the app, since its window exists by then.
278
+
279
+ - ccf2ae2: An installed app updates itself: a phone its web bundle over the air, a desktop app the whole app.
280
+
281
+ - `native.updates: { url, publicKey, channel?, readyTimeout? }` in `akan.config.ts`, per target too, the target's
282
+ fields winning. It brings the runtime's `updates` plugin. A channel left unnamed is the backend env the binary is
283
+ built for (`main`, `develop`, `debug`, …), so a `build-desktop` app (`debug` unless `--env` names another) takes
284
+ only the releases published for that env.
285
+ - The CSR frame confirms a release on trial once the first page is on screen, in every native shell (an unconfirmed
286
+ one is rolled back at the next launch). On a phone it also keeps the bundle current by itself: at start and on each
287
+ return to the front it looks for a newer one and downloads it, and it runs from the next cold start. A desktop
288
+ release is the whole app and a relaunch, so the app checks, downloads and applies it on its own schedule. A dev
289
+ build's pages are left alone. `akanjs/client/native` exports `updates`, `markReady` and `useUpdateState`.
290
+ - `akan update-keygen <app> [--platform]` makes the signing key of the app's id once and prints its public half.
291
+ `akan publish-update <app>` builds a release on this machine and signs it: the whole app for this computer's desktop
292
+ OS and CPU (with the app's server when `desktop.server` says so for the target, as for `build-desktop`), or the
293
+ web bundle for `--platform android|ios`. It writes the manifest and its files under `.akan/native/<target>/updates`,
294
+ to upload to `updates.url`, on the channel of its `--env` (`main` unless named). `--channel` names only the manifest
295
+ written, not the channel the release follows, so a pilot group gets a target whose `updates.channel` is the pilot's.
296
+ - The updates folder holds only what is uploaded, and whoever can reach `updates.url` reads it: the updater sends no
297
+ credentials, and a desktop release is the whole app, with the carried server's `private/` (the app's and its libs')
298
+ and its env file.
299
+ - `akan pack-update <app> --platform ios|android [--target] [--env] [--out]` writes an unsigned phone update instead,
300
+ for a signer that keeps the key elsewhere: `files/<sha256>`, `bundle.json` and `manifest.template.json`, the
301
+ manifest with `channel`, `sequence` and `bundle` left for the signer. `--against <store bundle.json>` also checks
302
+ the bundle runs in that store build, writes `compat.json`, and fails when it needs a new binary. The signing
303
+ contract is in the native runtime's architecture notes: fill the three fields, sign exactly the bytes you upload,
304
+ upload `files/` first.
305
+ - `publish-update` refuses a `--channel` outside the names `updates.channel` accepts (lowercase letters, digits, `.`,
306
+ `_`, `-`) before it builds, and numbers a release past the one its channel already has in the updates folder,
307
+ warning when this computer's clock is behind it. Apps compare against the time their own build was made, so keep the
308
+ building and the publishing computers' clocks in step.
309
+ - While a desktop release is on trial, `updates.check()` answers `available: false` for it and `updates.apply()`
310
+ rejects with `NOT_ALLOWED`: applying would replace the app a failed trial goes back to.
311
+ - A release of a desktop app that carries its server is confirmed only once that server answered ready and stayed up
312
+ for 5 s, and only while it is up; its trial clock (`readyTimeout`) starts then, so a server slow on its first run
313
+ (its new files being scanned) costs no rollback. A server that gives up, or is not up within 120 s of the start,
314
+ rolls the release back at once; the release stays downloaded for the next apply, and its third such failure
315
+ excludes it for good.
316
+ - A desktop release's manifest says whether it carries a server, and an installed app refuses one that differs from
317
+ itself before downloading anything (`check()` answers `available: false`, `download()` rejects with
318
+ `NOT_ALLOWED`), since either way its pages' backend would change place. `publish-update` refuses, before it builds,
319
+ a desktop release whose server presence differs from the channel's previous one: publish it on another channel
320
+ (`updates.channel`), or remove that `<channel>.json` from the output folder to start the channel over. Turning
321
+ `native.desktop.server` on or off for an app already installed takes a reinstall.
322
+ - On Windows an app started from its install folder (the installer, the Start menu, Explorer) applies and rolls back
323
+ updates: Windows renames no folder a process works in, so the app moves to its local data folder before the webview
324
+ starts, and the update helper and the app it starts work elsewhere.
325
+ - The update state and the unpacked releases live in the app's local data (`%LOCALAPPDATA%\<app id>\akan-native-updates`
326
+ on Windows), and plugins get that folder as `ctx.appLocalDataDir`.
327
+ - An app installed under another folder name (`/D=`, a renamed `.app`) takes updates; a download older than a
328
+ reinstalled build is dropped; a Windows swap that did not happen (a file stayed locked, the helper never ran) keeps
329
+ the release downloaded for the next apply until its third such failure excludes it. Reinstalling the app clears the
330
+ list of releases the earlier install refused.
331
+ - `publish-update` checks every target before anything builds — its `updates` settings, its native config, the
332
+ signing key on this computer, and whether its channel's previous release carries a server as it would — so a CI
333
+ without the key stops at once and a publish never releases only the first targets. It writes `<channel>.json`
334
+ together with its signature.
335
+ The channels `bundle`, `manifest.template` and `compat` are refused. `akan start` keeps `.akan/native`, where
336
+ releases wait to be uploaded, and the `bin` downloads in `.akan/cache/bin`.
337
+
338
+ - ccf2ae2: An app image carries the server env of the environment it runs and no other.
339
+
340
+ - `env/env.server.ts` imports every environment's file, so `akan build` used to bundle all of them into `server.js`:
341
+ anyone holding the image of one environment could read the keys of every other. The backend build now keeps only
342
+ `env.server.<AKAN_PUBLIC_ENV>.ts`, the environment the generated Dockerfile fixes, and swaps each other
343
+ environment's file (`local`, `testing`, every branch) for exports that refuse to be read. Booting such an image under
344
+ another `AKAN_PUBLIC_ENV` stops with `env/env.server.<name>.ts is not in this build` instead of running on a
345
+ config it does not have. Nothing in `env/` needs to change.
346
+ - An app's generated `server.ts` no longer re-exports `env` from `env.server.testing.ts`, which put the testing env in
347
+ every image. Signal tests read it from the file. A script or test that imported `env` from an app's `server.ts`
348
+ imports `./env/env.server.testing` instead. A lib's `server.ts` still exports it, since each app's
349
+ `env.server.type.ts` spreads it as the lib's defaults.
350
+ - Keys that already shipped in an image stay readable in it. Rotate them.
351
+ - A build for a named `--env` (a mobile or desktop build) writes that env into the Dockerfile too, so the image boots
352
+ the environment its `server.js` carries.
353
+
354
+ **Breaking for an image started under another `AKAN_PUBLIC_ENV`, and for code importing `env` from an app's
355
+ `server.ts`:** the image stops at boot until it runs the environment it was built for, and the import moves to
356
+ `./env/env.server.testing`.
357
+
358
+ - ccf2ae2: A desktop app nobody attends recovers its page, relaunches itself and opens as a kiosk
359
+
360
+ `native.desktop.recovery: "reload"`, for the app or one target, loads a page whose web process ended (a crash, a hang,
361
+ out of memory) again, once per end, instead of showing an error page after the second end, waiting longer after each
362
+ end in a row (1 s, doubling to a minute). It relaunches the app when the webview's browser process ends, where the app
363
+ used to quit: at once the first time, then after 1 s doubling to a minute while each relaunched app dies within a
364
+ minute, and after ten in a row it exits with code 1 instead of restarting for good.
365
+ `native.desktop.window: { fullscreen, skipTaskbar }` opens the main window borderless fullscreen and without a taskbar
366
+ button (Windows, Linux) from its first frame; a plugin's launch phase decides the same per launch with
367
+ `ctx.launch.setWindow`. `app.relaunch()` ends the app and starts it again in a new process on the desktop and Android,
368
+ and reloads the page on the web; one process starts one new app, however many windows or calls asked for it.
369
+ `native.android.autoplay: true` lets media play with sound without a tap first, as it already does on iOS and the
370
+ desktop.
371
+
372
+ An app relaunched by an update on Windows no longer quits the moment it starts when `akan start-desktop` had started
373
+ the one before it.
374
+
375
+ - ccf2ae2: feat(native): Android back belongs to the page only while it has somewhere to go, a back swipe moves the page, and
376
+ memory warnings release hidden pages
377
+
378
+ - **`app.setBackEnabled({ enabled })`** (Android) tells the shell whether a listening page wants back right now. The
379
+ CSR frame reports it after every navigation: enabled while there is history, the keyboard is up, or the index is
380
+ still to come. At the index with nothing under it the shell no longer registers its back callback, so the system
381
+ shows its back-to-home animation and the app stays warm. Before, the page caught that back and called
382
+ `app.exit()`, which finished the task. A new listener starts enabled, so a page that never reports behaves as before.
383
+ - **`app` event `backProgress`** (Android 14+, `OnBackAnimationCallback`) carries `{ phase, progress, swipeEdge }` for
384
+ a back the page will take. The frame drives the current transition's spring with it: a `stack` or `bottomUp` page
385
+ follows the finger, a fade or `scaleOut` page moves half as far, and a cancelled swipe springs back. The commit
386
+ still arrives as `backButton`, and the back animation finishes from wherever the finger let go.
387
+ - **`app-state` event `memoryWarning`** `{ level: "moderate" | "critical" }`: iOS `didReceiveMemoryWarning`, Android
388
+ `onTrimMemory` (the `RUNNING_LOW` / `RUNNING_CRITICAL` levels Android 13 and earlier still send, `BACKGROUND` and
389
+ above) and `onLowMemory`. The frame releases its hidden pages on one; each mounts again when visited.
390
+ - The runtime's plugin host gained `setBackEnabled` and `setBackProgressListener`; the desktop `app` implementation
391
+ answers both as no-ops.
392
+
393
+ Checked on an Android 16 emulator: at a fresh index, back went to the system and the process survived. A left-edge
394
+ swipe on a stack page moved it by the finger (translateX ~76px mid-swipe) and snapped back on cancel; a committed swipe
395
+ went back through `router.back`. `am send-trim-memory RUNNING_CRITICAL` left only the current and previous pages. On an
396
+ iOS 26.5 simulator, Simulate Memory Warning reached the page, and killing the WebContent process reloaded the page onto
397
+ its restored stack.
398
+
399
+ - c45ac6c: perf(server): stop rebuilding per-row and per-request state on the backend hot paths
400
+
401
+ A profile of the backend (`constant` → `signal`) found the database was 3% of a list query and JS
402
+ post-processing was the other 97%. On a 50-row `user` query: 14µs of SQL, then 236µs decoding rows and 221µs
403
+ hydrating them, allocating ~23 000 objects — about 460 per document returned. None of that was the query.
404
+
405
+ The same query now runs in **276–293µs and allocates ~3 100–3 900 objects**: −33% latency, −85% allocation.
406
+ Four changes, each measured independently.
407
+
408
+ **`hydrate()` no longer snapshots rows it does not have to.** Every row of every query was deep-cloned —
409
+ `JSON.parse(JSON.stringify(sanitizeJson(row)))`, 172µs of the 412µs — purely to back `isModified()`. That
410
+ method has two call sites in a real app, both inside `schema.pre("save")` hooks, and a save hook only ever
411
+ runs on a document that came from `create()` or the update path. `hydrate` takes a trailing
412
+ `{ track }` that **defaults to `true`**, so every caller outside the store — including the `Model(data)`
413
+ facade — keeps today's behaviour; only `find` and the projected read behind it opt out.
414
+
415
+ `isModified()` on an untracked document **throws** with a message naming the fix, rather than guessing.
416
+ Both guesses are silently wrong: `false` skips work that was needed, and `true` makes a
417
+ `if (!this.isModified("password"))` hook re-hash an already-hashed password. If you hit it, you are calling
418
+ it on a document that came straight out of a read — move the call into a save hook, or re-load through
419
+ `save()` / `update()`.
420
+
421
+ The snapshot stays a deep clone where it is taken. A lazy one would be wrong: `isModified()` compares against
422
+ state as of hydrate, and document chains mutate `this.status = …` directly, so there is no mutation hook to
423
+ defer to. The six per-document closures (`set`, `save`, `refresh`, `isModified`, `toJSON`, `toObject`) also
424
+ moved to one prototype per store; it extends the model's own document prototype, so chain methods and
425
+ `instanceof` are unaffected and the methods stay non-enumerable exactly as before.
426
+
427
+ **`ConstantField.getProps()` is memoized.** It rebuilt a 27-key object on every call, and the read paths ask
428
+ per field per row — in `decodeDocumentPayload`, again in `crystalize` via `BaseConstant.set()`, again in
429
+ `purify`. It is now built once per field and frozen; no caller mutated the result.
430
+
431
+ **`getDefault()` caches a plan, not a record.** `via()` already memoized it per class, but the database
432
+ adaptor called the standalone function per nested scalar value per row. Thunk defaults
433
+ (`default: () => dayjs()`), array defaults and nested-scalar defaults are still produced per call — only
434
+ values that were already shared before the cache existed are shared now. Caching the finished record would
435
+ have frozen `dayjs()` at boot and aliased one `[]` across every document filled from that model.
436
+
437
+ **Middlewares, guards and slice projections resolve once instead of per request.** `use(env)` takes no
438
+ context, so a middleware instance and its handler are a function of `(class, env)` and now live for the
439
+ process; a rejected setup is evicted so a transient failure does not poison the endpoint. Guards are already
440
+ required to be side-effect free and re-runnable — `revalidateWsRooms` re-runs them outside any request — so
441
+ one instance per class serves every call. `Logging`, which is registered by default, built two template
442
+ strings per request and discarded them unbuilt at the default log level; it now checks
443
+ `Logger.shouldLog("debug")` first (new public method, alongside the existing `isVerbose`).
444
+
445
+ **`WebRouter` stops rebuilding the merged manifest per request.** `#ensureRoute` took a `snapshot()` and then
446
+ merged the runtime manifest over it on every `/*` and `/__rsc` request — four copies of a structure that is
447
+ 276KB of JSON for a small app. `RouteClientCache` now exposes a `revision` that bumps on every mutation of
448
+ `merged`, including a delta merge that leaves `generation` alone, and the merge is memoized against it. In
449
+ production the revision never moves after `seed`, so the manifest is built once; in dev a rebuild bumps it and
450
+ the next request pays for one copy. The cached object is still a copy, so an in-flight request keeps consuming
451
+ a stable manifest across an invalidate, exactly as the per-request snapshot guaranteed.
452
+
453
+ **`routeElementComposer` and `routeTreeBuilder` are no longer re-exported from `akanjs/server`.** They pull
454
+ React and the page module graph, and the only consumer is `rscWorker.tsx`, which imports them relatively in its
455
+ own process. Re-exporting them put React into every process touching the barrel — including a
456
+ `SERVER_MODE=batch` replica that renders nothing and any `init({ web: false })` server. The barrel import drops
457
+ from **44.0MiB / 28 338 objects to 38.8MiB / 24 640**. Import them at `akanjs/server/routeTreeBuilder` and
458
+ `akanjs/server/routeElementComposer` if you need them directly.
459
+
460
+ The DI container was measured too and left alone: 11 services, 11 signals, 26 adaptors, 8 database models and
461
+ 7 scalars cost 3.7MiB and 27ms, which is 2.5% of process RSS. `diLifecycle.ts` is not where backend memory
462
+ goes — the module graph is.
463
+
464
+ - 69f7178: feat(server): boot a subset of modules with `modules`
465
+
466
+ `new AkanApp("./server", { modules: ["article"] })` mounts only the named modules and the ones they reach; every
467
+ other module stays out of the container, so its service, signal, routes and scheduled jobs do not exist. Omitted
468
+ or empty keeps today's behaviour — every module whose service is enabled. This is what lets one codebase run as
469
+ several small processes, such as a batch worker that carries only its own domain.
470
+
471
+ Dependencies are followed, so you name entry points rather than the whole graph. The closure takes the services
472
+ and signals a module injects, and also its cascade edges: a `removeRef` target and a monomorphic `removeWith`
473
+ owner both fail `CascadeRunner.seal` when absent, so they are boot requirements the inject graph cannot see. A
474
+ polymorphic `refPath` candidate is left out, matching the existing rule that an unmounted one is a mount choice
475
+ rather than a typo. What was mounted is named at boot:
476
+
477
+ ```
478
+ [DiLifecycle] INFO Mounting 3 of 12 module(s): article, file, user
479
+ ```
480
+
481
+ A name no module registered fails the boot instead of being ignored — a typo would otherwise drop a module
482
+ silently, which is the one failure the option exists to prevent. Selection narrows the enabled set rather than
483
+ replacing it, so it never turns on a module whose service is `enabled: false`.
484
+
485
+ The same selection is available as `AKAN_MODULES=article,file` for a deployment that decides the split, and as
486
+ `{ modules }` on `AkanServer` for an app that starts the server directly. `AkanApp` hands its own option down to
487
+ every child through that variable, since each replica builds its own container.
488
+
489
+ `DiLifecycle`'s constructor now takes `({ env, modules }, ...libs)` instead of `(env, serverMode, ...libs)`. The
490
+ `serverMode` argument was unused — schedule registration takes its own — so it went with the change.
491
+
492
+ - 5f53462: refactor(server): remove the `@CacheMethod` decorator
493
+
494
+ It never hit: it called the model cache as `get(key)` / `set(key, …)` while that cache takes `(topic, key)`, and
495
+ nothing in the framework or its libs used it. Cache an endpoint's answer with `{ cache: <ms> }`, or hold state in a
496
+ service with `memory(...)`.
497
+
498
+ - 5f53462: feat(constant): `cascade: "removeWithAny"` lets a cascade name any model as its owner
499
+
500
+ A polymorphic `removeWith` required its `refPath` to name an `enumOf`, so the one shape an enum cannot express
501
+ had no cascade at all: a child whose owner may be _any_ model in the app — a reaction, a comment, an attachment
502
+ that later models opt into. Listing the candidates in the child's own enum inverts the direction `removeWith`
503
+ exists for, where the owner never learns its children exist.
504
+
505
+ That declaration is now available, opt-in and priced, as a third cascade action:
506
+
507
+ ```ts
508
+ parent: field(ID, { refPath: "parentType", cascade: "removeWithAny" }),
509
+ parentType: field(String), // holds the owner's refName
510
+ ```
511
+
512
+ The widening is the action, not a flag beside it — `removeWithAny` names the whole decision in one value, so it
513
+ cannot be declared apart from the direction it widens.
514
+
515
+ Every model's removal then sweeps the child by the removed model's own refName. The lookup is cheap — the
516
+ declaration auto-creates the same `{ removedAt, typeKey, fk }` index it always did, so a removal that owns
517
+ nothing is one index probe returning zero rows, not a table scan. The real price is bulk: a query-level removal
518
+ of _any_ model would be a removal whose wildcard children were never looked for, so **one wildcard edge anywhere
519
+ turns every cascade in the app back to one document at a time**. The boot log says so in one `info` line naming
520
+ the edges, because "why does this app remove everything per document" must be answerable without reading a
521
+ model file. An app that declares none keeps the fast path exactly as before.
522
+
523
+ Refused while the class is built: `removeWithAny` on a `refPath` that already names an `enumOf` (the enum
524
+ names the candidates and indexes better — keep one), a `typeKey` that is not a `String` (the sweep matches a
525
+ refName against that column, and a column that cannot hold one silently finds nothing, which is
526
+ indistinguishable from never having declared the cascade), and `removeWithAny` on a field with no `refPath`.
527
+ A wildcard owner is also exempt from the mount check a monomorphic owner fails at boot — it names no module to
528
+ mount.
529
+
530
+ - ccf2ae2: feat(csr): every history entry is a page of its own, the stack keeps three hidden entries, and a dev server can swap
531
+ route modules in place
532
+
533
+ The CSR frame rendered one container per route pattern. A push to the route on screen (`/item?id=1` → `/item?id=2`)
534
+ re-rendered the same page with new arguments: nothing sat under it for a swipe back, and going back handed the
535
+ first entry the second one's state.
536
+
537
+ - **A page container is a history entry.** A push mounts a new page over the old one, which waits under it with its
538
+ state. A replace within one route keeps the entry, so the page updates in place. A `cache` route is still one page
539
+ for the whole session.
540
+ - **The stack keeps the three nearest entries below the one a swipe back reveals,** mounted and hidden (their effects
541
+ stopped), so going back shows each with its state. Older entries are released and mount again on back, with
542
+ their scroll restored.
543
+ - `window.history` entries carry `{ akanEntryId }`, and popstate matches on it (falling back to the href), so two
544
+ entries with one address are told apart.
545
+ - Frame slot targets are per entry too: `<slot>Content-<key>`, with the key in `pathContext.pageKey`. `Navbar`,
546
+ `TopInset`, `TopLeftAction` and `BottomInset` portal into their own page's target, and a page container is
547
+ `#pageContainer-<key>` with `data-path`.
548
+ - **`usePageLocation()`** (`akanjs/webkit`) is this page's own `{ pathname, params, searchParams }`. `st.use.*` follows
549
+ the page on screen, so a page being prepared read the previous page's `?filter=`. `Data.ListContainer` and
550
+ `Data.Dashboard` now seed from their own page.
551
+ - **`replacePages(context)`** (`akanjs/webkit`) rebuilds the route table from new route modules while history,
552
+ mounted pages and stores keep going. It resolves `false` when the set of modules changed, which needs a reload.
553
+ The table itself moved to `CsrRouteTable`, which `bootCsr` now boots.
554
+ - Same-route navigation now replays the enter animation and resets the spring after back, since both follow the
555
+ entry rather than the pathname.
556
+
557
+ - ccf2ae2: feat(csr): the page stack outlives a reload, popstate lands on any entry, and the page under the current one pauses
558
+ in the background
559
+
560
+ - **A reload keeps the stack.** The router writes its entries (`href` + `akanEntryId`) to `sessionStorage` on every
561
+ settled navigation and reads them back at boot when the entry the tab reopened on is the one it was on. That
562
+ covers a reload and a WebView whose content process died and reloaded. Only the current and previous entries
563
+ mount; the rest are dormant until visited. Before this, back after a reload changed the address and left the
564
+ page where it was.
565
+ - **Popstate lands on the entry it names,** not only a neighbour. A long-press back menu or `history.go(-n)` jumps
566
+ there. An entry the stack never saw (from before a reload that kept no stack, or a hash the browser pushed)
567
+ replaces the current one, in place when the route is the same.
568
+ - A popstate whose `hasUAVisualTransition` is set (Safari's own swipe back) skips the frame's back animation, so the
569
+ page does not slide away twice.
570
+ - While the document is hidden (the app in the background), the page under the current one pauses its effects as
571
+ well. `RouteState.isBackgrounded` carries it.
572
+ - On a macOS desktop shell, ⌘[, ⌘← and the mouse back button go back; a text field keeps ⌘←. WebView2 and the browser
573
+ already do this themselves. `desktopPlatform()` is exported from `akanjs/client/native` beside `nativePlatform()`.
574
+ - `useHistory(locations, { idx, dormant })` takes a restored stack, and `setHistoryJump(idx)` moves several entries
575
+ at once.
576
+
577
+ - ccf2ae2: A desktop app can carry the app's own server: with `native: { desktop: { server: true } }` in `akan.config.ts`, or
578
+ `desktop.server` on one target, `akan build-desktop`, `akan start-desktop --release` and `akan publish-update` put the
579
+ backend `akan build` made into the app, so it works on one computer with no backend elsewhere.
580
+
581
+ - The build stages what the backend build wrote into `apps/<app>/.akan/desktop/server` — `main.js`, `server.js`, the
582
+ chunks, `akan.build.json`, `private/`, and any `.node`, `.wasm` or file asset a bundled package brought — without the
583
+ Dockerfile, the RSC worker, the console, `csr/` and `public/`. Its packages install there first
584
+ (`bun install --production --prefer-offline`, without the RSC renderer and the drivers of every database mode but
585
+ `single`), before `akan build` runs, so a computer that cannot reach the registry or find them in Bun's cache stops at
586
+ once. The app needs `single` in `database.modes`; the command says so before it builds.
587
+ - The backend is built for the command's own `--env`, not the workspace's `AKAN_PUBLIC_ENV` (the root `.env` usually
588
+ names `local`), so `server.js` holds that environment's `env.server.<env>.ts` and no other.
589
+ - What it carries is readable in plain text by anyone with the app: `private/` (each lib's too, under
590
+ `private/libs/<lib>`), that `env.server.<env>.ts` and the server env defaults of the libs it uses (each lib's
591
+ `env.server.testing.ts`). Keep deployment secrets out of them. It has no `public/`, and its working
592
+ folder is its data folder: read a runtime file from the app folder (`AKAN_APP_DIR`, else the folder of `Bun.main`),
593
+ never from `process.cwd()`.
594
+ - At launch the shell starts it as a child on its own Bun (`BUN_BE_BUN`, with `.env`, `bunfig.toml` and
595
+ auto-install off) on a loopback port, and hands the page its URL as `PUBLIC_AKAN_SERVER_URL`. The port of the last
596
+ session comes first and a fresh one is picked only when it is taken, so an address registered somewhere usually
597
+ stays valid from one launch to the next. The window waits for it up to 8 s.
598
+ - It runs API only, `operationMode` edge, database mode `single`, SSR, CSR and MCP off, bound to 127.0.0.1 with every
599
+ other Host refused. Any program on the computer can still call that port, so guard its endpoints as a network
600
+ server's. It keeps its SQLite data, files, logs and a per-install JWT secret in the app data folder's
601
+ `server/`, and a debug build, which has the release app's id, in its own `server-debug/`. On Windows that folder is
602
+ `%LOCALAPPDATA%\<app id>\server`, not the Roaming folder a profile copies on every sign-in and an organisation may
603
+ put on a file share, where SQLite's WAL does not work. Bun's transpiler cache goes to
604
+ `<server data>/runtime/transpiler-cache`.
605
+ - It trusts the operating system's CAs (`--use-system-ca`), as the page does, and gets the proxy and CA variables
606
+ (`HTTP(S)_PROXY`, `NO_PROXY`, `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `NODE_USE_SYSTEM_CA`) and the desktop session's
607
+ (`DISPLAY`, `WAYLAND_DISPLAY`, the session bus, the audio server, `XDG_*`, and Windows' `ProgramFiles`, `ComSpec`,
608
+ `PATHEXT` and the like), so a `bin` tool reaches what the page reaches and can open the screen, the audio server or a
609
+ shell.
610
+ - `AkanApp` takes `BUN_BE_BUN` off `process.env` at boot and gives it back only to a spawn of its own executable (the
611
+ ops snapshot, which also gets the server's runtime flags), so a `bin` tool built with `bun build --compile` starts as
612
+ itself rather than as the Bun CLI. A child started with `Bun.spawn` and no `env` still gets the environment the
613
+ process started with, so pass `env: process.env`.
614
+ - A crash restarts it on the same port (1 s doubling to 30 s; the fifth in a row gives up with an alert). A server that
615
+ exits before its first ready starts again 250 ms later, on the same port while it is still free, so one that cannot
616
+ boot gives up while the window still waits for it and the window opens at once with the alert. A server
617
+ that cannot start at all (an unreadable `jwt.secret`, a data folder that cannot be made) still hands the page a
618
+ loopback URL and shows the same alert once the window is up, so the page never falls back to the backend its bundle
619
+ was built for. A restart that throws counts as one failure. Its standard error, and its standard output until it is
620
+ ready, also go to `server/runtime/logs/server-output.log`, where a server that fails before it listens leaves its
621
+ reason.
622
+ - Quitting the app stops it within its 1 s shutdown budget, inside the launcher's 1.5 s grace, and a server still
623
+ there after the grace is killed. On macOS and Linux it leads its own process group, and whatever it started ends
624
+ with it: the launcher ends the group when the app quits, and when the app was killed or crashed — even while the
625
+ server was still starting — the server sees its parent gone and ends the group itself, SIGTERM first and SIGKILL a
626
+ second later. A process started `detached` leaves the group. On Windows the job object ends them all with the app.
627
+ - It runs in one process: a `main.ts` or an env asking for a gateway and replicas (`replica`, `solo: false`,
628
+ `AKAN_SOLO=false`, `AKAN_REPLICA`, `AKAN_COMMAND_TYPE=start`) does not boot in the app, and says which.
629
+ - A native plugin follows the server with `ctx.server.state` and `ctx.server.onState` (`starting`, `up`,
630
+ `restarting`, `gaveUp`, `stopped`), and a page with the `app` plugin's `serverState` event, so an app nobody attends
631
+ can act on a server that gave up (`app.relaunch()`, say).
632
+ - `XAUTHORITY`, `PULSE_COOKIE`, `PULSE_RUNTIME_PATH` and `XDG_DATA_DIRS` reach it too. A second launch that hands
633
+ over to the first starts none. The single-instance plugin comes with it.
634
+ - `akan start-desktop` without `--release`, for such a target, follows this checkout's dev server already answering on
635
+ the app's dev port, or starts `akan start` in the same command and opens the app once it serves; it asks for one
636
+ target before it starts one, needs `single` as a release does, and stops at once when that dev server fails to boot
637
+ (a replica's crash loop included) instead of waiting it out. Ctrl+C or closing the app stops the app, then the dev
638
+ server it started, then the local database, one after another, and exits 130.
639
+ - `akan start-desktop --release` of such a target shows the carried server's lines at the level its logger wrote them.
640
+ - `AkanAppConfig.getProductionEnv()` is the env the image runs with; the Dockerfile's `ENV` lines come from it (same
641
+ keys and order, without the two blank lines the template used to leave).
642
+
643
+ - ccf2ae2: A desktop app reads and sets the system volume and mute, and hears when they change
644
+
645
+ The `volume` native plugin (`native.plugins: ["volume"]` in `akan.config.ts`, `volume` from `akanjs/client/native`)
646
+ has `getVolume()`, `setVolume({ level })` and `setMuted({ muted })`, each answering `{ level, muted, settable }`, and
647
+ a `change` event whoever moved the volume. macOS drives the default output device through CoreAudio (an HDMI or
648
+ DisplayPort output takes no volume from the computer and answers `level: null`), Windows the default render endpoint
649
+ the taskbar slider moves, and Linux the default sink of PulseAudio or PipeWire through `pactl`, in any desktop
650
+ language, hearing changes again after the audio server restarts. A phone or the web answers `UNSUPPORTED`, and a Linux
651
+ box without an audio server `NOT_FOUND`.
652
+
653
+ - ccf2ae2: `akan build-desktop --arch arm64|x64` picks the CPU a Windows or Linux desktop app runs on
654
+
655
+ A desktop app is still built on its own OS, now for either of its CPUs: the Rust library is built for that target,
656
+ the executable with `bun build --compile --target=bun-<os>-<arch>`, the carried server installs with
657
+ `bun install --cpu=<arch>` so its addons' prebuilt binaries are that CPU's, and each `bin` entry is that platform's.
658
+ The setup program and the AppImage name the CPU they hold. A macOS app is Apple silicon only: Intel Macs are not a
659
+ target, so `--arch x64` on macOS is refused.
660
+
661
+ - ccf2ae2: A desktop build stops on a server addon that would not load on a user's computer
662
+
663
+ Before it copies the server, a desktop build reads every `.node` file in it by package. A package with no binary for the
664
+ target OS and CPU, a binary that links a library by an absolute path outside the system (a ROS install under `/opt`,
665
+ Homebrew's prefix) or names such an rpath, and a package with a `binding.gyp` that its install never compiled (Bun runs
666
+ no install script of an untrusted package: add it to `trustedDependencies`) are listed and stop the build, instead of
667
+ failing at the first `require` on the user's computer. An addon node-gyp compiled on the build machine only warns.
668
+
669
+ - ccf2ae2: `bin` in `akan.config.ts` puts executables in a desktop app, and the build says what the image installs that the app does not
670
+
671
+ A desktop app ran whatever `ffmpeg` the user's computer had on its PATH, if any, and a Finder-launched app sees only
672
+ `/usr/bin:/bin:/usr/sbin:/sbin`. `bin` names an executable and, per platform (`darwin-arm64`, `win32-x64`, …), where it
673
+ comes from: a download with its `sha256` (an archive takes `file`, the executable inside it) or a path next to the
674
+ declaring `akan.config.ts`. `build-desktop` and `start-desktop` fetch the file for the computer they build on, check it,
675
+ and carry it in the app's `bin/`, which the app puts first on its PATH: the carried server's `spawn("ffmpeg")` runs
676
+ that file, and a native plugin finds it in `ctx.binDir`. A download is kept as `download` plus its URL's extension
677
+ (`.zip`, `.tar.xz`, `.exe`, …), never under a name the URL spells. Bun's own `spawn` without `env` reads the environment the app
678
+ started with, so a plugin passes `env: process.env` to run one by name. A lib's entries reach the apps that depend on
679
+ it, and an app's own entry of the same name wins. macOS builds sign every executable the app carries, in `bin/` and
680
+ in the carried server, found by its Mach-O header rather than its name, so one a package ships without an extension
681
+ passes Developer ID signing and notarization too. The image does not read `bin`: it still installs through `docker`.
682
+
683
+ `build-desktop` of a target that carries its server now warns when the image runs `docker` steps (or the app writes
684
+ its own Dockerfile) and the app carries no `bin`, since the carried server runs none of those steps.
685
+
686
+ - ccf2ae2: `desktop.server: { omit }` carries the desktop app's server without packages only the image needs
687
+
688
+ `native: { desktop: { server: { omit: ["rclnodejs"] } } }` carries the server as `server: true` does, minus the named
689
+ packages and whatever only they pull in: an addon tied to the image's system (a ROS install), or code only a process
690
+ the desktop app never starts loads. The image's `package.json` keeps them, `externalLibs` included; the desktop
691
+ server's `package.json`, its `bun install --production` and the addon check do not see them. A package another
692
+ dependency still installs stops the build, naming the dependents, and the targets that carry one build's server must
693
+ omit the same packages.
694
+
695
+ - ccf2ae2: A desktop app's server reads the files the user picks, with no copy and no path in the page
696
+
697
+ `filePicker.pickFiles({ forServer: true })` (also `pickDirectory` and `saveFile`; `filePicker` is now exported from
698
+ `akanjs/client/native`) copies nothing on the desktop, whatever the file's size: its FileRefs serve the originals,
699
+ and each result carries a grant. The server exchanges the grant with `NativeFile.resolve(grant, "read" | "write" |
700
+ "folder")` from `akanjs/server`, or `NativeFile.resolveIn(folderGrant, relative)`. The carried server asks the shell
701
+ that showed the dialog over its IPC channel, so it reaches what the user picked and nothing else; a debug build
702
+ that carries its server does the same. Behind a dev build without one, `akan start` checks the grant's signature
703
+ instead, in `operationMode` local only. A phone or the web refuses `forServer`.
704
+
705
+ `resolveIn` judges a relative path by where it lands once the links on it are resolved — through its nearest existing
706
+ folder when the file is about to be written — so a link inside the granted folder cannot take it elsewhere, and a
707
+ dangling link is refused. A name that starts with two dots (`..cache/x`) is inside the folder, not above it.
708
+
709
+ - 5f53462: fix(signal): an endpoint's `cache` is looked up after its guards, not ahead of the account
710
+
711
+ The `Cache` middleware sat ahead of every middleware a lib registers, `AccountMiddleware` among them, so on a hit it
712
+ ran the guards before anyone had resolved the caller — a guard reading the account saw none and refused a signed-in
713
+ caller the second time they asked. The lookup is now a step of the call itself: guards, then internal arguments,
714
+ then the cache, then the handler, inside the `timeout` deadline as before.
715
+
716
+ `Cache` is no longer exported from `akanjs/signal` and is no longer registered as a middleware, and
717
+ `SignalContext.checkGuards()` — which existed only for it — is gone. `{ cache: <ms> }` on an endpoint is unchanged.
718
+
719
+ - ccf2ae2: Every builtin native plugin's page API is `akanjs/client/native/<plugin id>`
720
+
721
+ `akanjs/client/native` carries the plugins every page uses, and the rest — `window`, `screen`, `global-shortcut`,
722
+ `keep-awake`, `autostart`, `single-instance`, `screen-orientation`, `clipboard`, `network` and every other builtin — had
723
+ no path an app outside this repository could import, since the runtime's own package ships only inside akanjs. Each is
724
+ now `akanjs/client/native/<id>`, for example `import { appWindow } from "akanjs/client/native/window"`.
725
+
726
+ - 2d8de1c: fix(service): `ServiceModel.getFilterServiceMethods` takes the filter's `FilterInfo` and builds what the runtime builds
727
+
728
+ **Breaking for a direct caller.** The static took a bare query function and guessed the trailing query option by its
729
+ shape, so it disagreed with the filter methods every booted model and service actually carries: `{ sample: 2 }` was
730
+ dropped instead of passed on, `{ sort: null, limit: null }` became a filter argument, omitted arguments were not
731
+ filled, and a filter whose query uses its `q` helper threw because no helper was passed.
732
+
733
+ It now takes the `FilterInfo` — `ServiceModel.getFilterServiceMethods(key, getFilterInfoByKey(Filter, key))` — and
734
+ reads arguments and options exactly as the runtime does, because the runtime now builds each model's and service's
735
+ filter methods through this same static. There is one implementation left. The returned table's type is unchanged.
736
+
737
+ - ccf2ae2: `akan build-desktop --installer true` on Linux adds an AppImage
738
+
739
+ The app folder gains an `AppRun`, a `.desktop` entry naming the app's icon and its `deepLinks.schemes`, and a 256px icon,
740
+ and is packed with `mksquashfs` behind the AppImage type 2 runtime, pinned to a dated release and checked by its
741
+ digest. The AppImage needs only the WebKitGTK 4.1 and GTK 3 the folder already needs, and runs without FUSE through
742
+ `--appimage-extract-and-run`. It runs from a read-only image, so the updates plugin cannot replace it: an app with
743
+ `updates` is warned to publish a new AppImage for each release.
744
+
745
+ - 5f53462: feat(document): a model loader remembers nothing unless it declares `cache`
746
+
747
+ `loader.byField` / `byArrayField` / `byQuery` built their `DataLoader` with the default `cache: true`, and a model's
748
+ loaders live as long as the process — so a key read once was answered from memory forever, however the document
749
+ changed. They now take an option after the default query, `{ cache }`: `false` by default, a number of milliseconds
750
+ to keep each key, or `true` for the life of the process. A failed load is never kept.
751
+
752
+ `DataLoader` itself now defaults to `cache: false` and accepts the same `boolean | number`.
753
+
754
+ - ccf2ae2: A macOS desktop release can be downloaded: Developer ID signing, the hardened runtime, notarization and a dmg
755
+
756
+ `akan build-desktop` signs with a Developer ID when `AKAN_NATIVE_MACOS_IDENTITY` names one in a keychain, or
757
+ `AKAN_NATIVE_MACOS_CERTIFICATE` + `_CERTIFICATE_PASSWORD` a `.p12` (imported into a keychain made for the build and
758
+ deleted after it, for CI). Every Mach-O file is signed inside out with the hardened runtime and a secure timestamp, and
759
+ the executable gets Bun's JIT entitlements plus the camera's or microphone's when a usage text asks for them;
760
+ `native.desktop.entitlements` adds the app's own. With `AKAN_NATIVE_MACOS_NOTARY_KEY` + `_KEY_ID` + `_ISSUER` (an App
761
+ Store Connect API key) or `_NOTARY_PROFILE`, the app is notarized, stapled and checked with `spctl`.
762
+ `--installer true` on macOS adds a dmg with an Applications link, signed, notarized and stapled the same way.
763
+ `publish-update` signs a macOS release with the same identity, since the updater checks the installed app's signature.
764
+ A release that is not signed with a Developer ID, or not notarized, warns that Gatekeeper blocks a downloaded copy.
765
+
766
+ - 69f7178: feat(mcp)!: expose every endpoint its guards admit, and mount `/mcp` by default
767
+
768
+ MCP exposure was opt-in per endpoint, per slice and per CRUD verb. It is now derived from the guards an endpoint
769
+ already declares, and `/mcp` is mounted unless `AKAN_MCP=false` says otherwise.
770
+
771
+ The old model asked the same question twice. `guards` is the authorization decision, and `McpDispatcher`
772
+ re-evaluates the account-scoped ones on every `tools/list` — so a second per-endpoint switch said nothing the
773
+ guards did not, while guaranteeing that every endpoint added later was invisible to agents until somebody
774
+ remembered to flag it. The framework's own OpenAPI document has no per-endpoint opt-in either: one env flag
775
+ publishes the whole HTTP surface, which is the same enumeration.
776
+
777
+ **The rule, applied globally with no way to configure it per endpoint or per app:**
778
+
779
+ - a real guard publishes
780
+ - **no `guards` at all is refused** — nobody decided who may reach it, and a catalogue entry is where that omission
781
+ would stop being invisible
782
+ - **a mutation whose only guard is `Public` is refused**, unchanged: `[Public]` is having none, spelled out
783
+ - an explicit `guards: [Public]` on a _read_ publishes. It is a decision someone wrote, the data is already served
784
+ anonymously over HTTP and in the OpenAPI document, and refusing it would break a public docs or search endpoint
785
+ while hiding nothing
786
+
787
+ Every structural refusal is unchanged: `pubsub`/`message`, an `Any` or `Upload` return, a file upload, a required
788
+ `Any` argument, and the three a prompt's flat string map cannot carry.
789
+
790
+ **Removed:** `mcp` on a signal option, on a slice option and on the signal's CRUD verb map — `McpOption` and
791
+ `McpSliceOption` are gone, and `mcp: { expose: true }` no longer typechecks. Delete it; nothing replaces it. The
792
+ `readOnly` / `destructive` / `idempotent` hints are now derived from the endpoint type and key rather than
793
+ overridable, which costs nothing a client trusts. `resource` is gone the same way: the generated reads are
794
+ addressable, a custom endpoint is not, and neither was ever configurable in practice.
795
+
796
+ `McpDocument.unguarded` is gone, subsumed by the refusal that now covers it. The `akan.mcp.missing-description`
797
+ and `akan.mcp.unguarded-exposure` quality-scan rules are gone with `McpScanner`: both found the exposure by
798
+ matching an `mcp: { expose: true }` literal in source, which no longer exists. The boot log already answered both
799
+ from the resolved catalogue, and it is now the only place that can.
800
+
801
+ **`GuardCls.scope` is required.** Unmarked meant `resource`, which is never evaluated for a listing — so with
802
+ exposure following the guards, one forgotten marker would list its endpoint's name and argument schema to every
803
+ caller and refuse only the call. There is no safe guess, so the author states it: `"account"` for a verdict that
804
+ reads the caller and nothing about the call, `"resource"` for one that needs the call's arguments.
805
+
806
+ **Upgrading:** delete every `mcp:` option from your signal files, and add `static scope: GuardScope = "account" |
807
+ "resource"` to every guard class. Then read the boot log — `MCP catalogue: tools=…` followed by one line per
808
+ refusal — because with no opt-in to be missing, that log is the only explanation for a tool that is not there.
809
+ An endpoint you want an agent to reach needs real guards, which it needed anyway.
810
+
811
+ **Settings moved off `new AkanApp(...)`.** `AkanAppOptions.mcp` is gone; the server settings live on the option
812
+ chain in `lib/option.ts` — `option.setMcp({ enabled, readOnly, path, version, instructions, allowedOrigins,
813
+ pageSize, language, auth })`. The old field is a type error on upgrade, which is the signal to move it.
814
+
815
+ - 5f53462: fix(service): `memory()` is scoped to its owner and does what its options say
816
+
817
+ - A non-`Map` memory was keyed by its property name alone, so two services that each declared `token` shared one
818
+ value. It is now stored under the owner's refName, like a `Map` memory already was. Values written under the old
819
+ key are not read back.
820
+ - `get` / `set` were type-checked and then ignored at runtime. They now run: `get` turns the stored value (a `Map`'s
821
+ entry value) into what the code reads, and `set` is its inverse.
822
+ - A declared `default` is what a missing value reads as on Redis too; Redis answers a missing key with `null`, which
823
+ used to skip it.
824
+ - `local: true` on a `Map` gives a `Map`, not `null`.
825
+ - The declaration's `expireAt` was one fixed time computed when the class loaded. It is replaced by `ttl` in
826
+ milliseconds, applied to each write that names no `expireAt` of its own.
827
+ - `RedisCache` expires a hash field on its own. `PEXPIREAT` on the whole hash expired every entry with the last one
828
+ written; each field's expiry now lives in a sorted set beside the hash (portable to Redis before 7.4), expired
829
+ fields read as missing, and they are dropped on every write and every listing.
830
+
831
+ - 3ebd8e1: feat(document): `updateById` / `removeById` on the model facade
832
+
833
+ The facade could already read one document by id (`findById`, `pickById`) but writing one meant spelling the id back
834
+ out as a query: `Model.updateOne({ id }, { … })`. It now carries the id-scoped pair directly.
835
+
836
+ ```ts
837
+ await this.Order.updateById(orderId, { status: "archived" });
838
+ await this.Order.updateById(
839
+ orderId,
840
+ { status: "archived" },
841
+ { upsert: true }
842
+ );
843
+ await this.Order.removeById(orderId);
844
+ ```
845
+
846
+ **These are the query-level writes narrowed to one id, not the document path.** They compile to the same single
847
+ atomic UPDATE as `updateOne` / `removeOne` — `removedAt IS NULL` ANDed in, counts returned rather than a document,
848
+ and **no hooks**, so no `_pre`/`_postUpdate`, no `_postRemove`, and no cascade. A model whose removal carries a side
849
+ effect or a `removeRef` / `removeWith` edge still goes through the service's `remove<Model>(id)`.
850
+
851
+ `updateById` takes the same trailing options as `updateOne`, so `{ upsert: true }` inserts with that id when nothing
852
+ matches. An `undefined` id is rejected by the query compiler rather than widening the write.
853
+
854
+ - 326da07: fix(document): give a Model's `insight<Query>` a real return type, and declare `db.<Model>Insight`
855
+
856
+ Declaring a filter query generates `count<Query>` and `insight<Query>` on both the Model and the Service. On the
857
+ Service the insight was typed; on the Model it came back as `unknown`, because `into()` passed `unknown` where
858
+ `QueryMethodPart` expects the insight type — so `await this.adminModel.insightByAccountId(id)` had no fields and
859
+ any use of the result needed a cast.
860
+
861
+ `into()` now threads the insight through from the constant model it already receives, typed as the document shape
862
+ because `insight()` accumulates into a plain record rather than a hydrated document.
863
+
864
+ The generated `lib/db.ts` also declares an insight document class per model and exports the type, mirroring what it
865
+ already did for `Input`:
866
+
867
+ ```ts
868
+ class AdminInsight extends by(cnst.AdminInsight) {}
869
+ export type { AdminInsight };
870
+ ```
871
+
872
+ `DatabaseRegistry.buildModel` takes that class instead of the constant insight, so `db.<Model>Insight` annotates
873
+ the result on both sides:
874
+
875
+ ```ts
876
+ const insight: db.AdminInsight = await this.adminModel.insightByAccountId(
877
+ accountId
878
+ );
879
+ const count: number = await this.countByAccountId(accountId);
880
+ ```
881
+
882
+ Run `akan sync <app-or-lib>` to regenerate `lib/db.ts`. A hand-written `DatabaseRegistry.buildModel` call — test
883
+ fixtures, mostly — now needs a `by()`-wrapped insight; wrap it after the `ConstantRegistry.buildModel` call, since
884
+ `by()` resolves the refName from the registry.
885
+
886
+ - ccf2ae2: A native platform section takes its own `indexPath`
887
+
888
+ `ios.indexPath`, `android.indexPath` and `desktop.indexPath` win over the section's (or the target's) `indexPath` on
889
+ that platform, so one target opens the phones on `/mobile` and the desktop app on `/` with no `--target`. It moves
890
+ both the dev build's first page and the `indexPath` a release bundle carries.
891
+
892
+ - ccf2ae2: Mobile apps build and run on `@akanjs/native` instead of Capacitor.
893
+
894
+ The runtime ships inside `akanjs` (vendored, not published on its own), so an app installs nothing extra for it.
895
+ It generates each target's native projects under `.akan/native/<target>/build/<platform>` and loads the web
896
+ root it assembles in `.akan/native/<target>/web` from the production CSR build — nothing native is committed.
897
+
898
+ - **Configuration (`akan.config.ts`).** A `native` section, and each of its `targets`, takes `appName`, `appId`,
899
+ `fileName`, `version`, `buildNum`, `basePath`, `indexPath`, `icon`, `splash`, `permissions`, `plugins` (builtin
900
+ ids such as `iap`), `deepLinks`, `ios.{infoPlist, entitlements, files}` and
901
+ `android.{manifest, application, activity, googleServices, files}`; a target overrides the section field by field.
902
+ `files` maps where a file lands (a path in the iOS app bundle, `res/<type>/<file>` or `assets/<path>` on Android)
903
+ to its source. A Capacitor-era key fails, naming the keys the section takes.
904
+ - **Plugins declare, they do not edit projects.** `AkanPlugin.native` names the permission it serves and what the
905
+ target then needs: native plugins, usage texts, plist and entitlement entries, Android permissions and features.
906
+ It replaces `capacitor.configureNative`, `editIosAppDelegate` and `runtimePackages`. A release build names each
907
+ plugin's default permissions as its capabilities.
908
+ - **Commands.** `build-`, `start-` and `release-ios|android` go through the runtime. `start-*` follows `akan start`
909
+ (it refuses when no dev server answers), runs one target at a time and takes `--device` (and `--team`/`-T` on
910
+ iOS); `--release` runs a release build of its own bundle. `release-ios` takes `--team` and `--ad-hoc`;
911
+ `release-android` builds an `.aab` by default (`--assemble-type apk` for an `.apk`), signed from
912
+ `MYAPP_RELEASE_STORE_FILE`, `MYAPP_RELEASE_STORE_PASSWORD` and `MYAPP_RELEASE_KEY_ALIAS` (plus
913
+ `MYAPP_RELEASE_KEY_PASSWORD` when the key has its own), which it checks before anything builds. `build-*` take
914
+ `--debug`, and
915
+ `akan doctor --ios` adds the native toolchain checks.
916
+ - **Pages.** `akanjs/client/native` wraps the native plugins a page uses, and `isNativeApp()` is true only inside
917
+ a shell. `Device` reads the runtime's device plugin and safe-area variables, `storage` the shell's preferences,
918
+ and the new `secretStorage` keeps the JWT and refresh tokens in the OS credential store (cleared on the first
919
+ launch after a reinstall). `useCamera` is one hook — the native sheet asks camera or library and answers an
920
+ upright JPEG data URL — and `useGeoLocation` answers the flat native position. A native release bundle opened at
921
+ `/` starts on its target's home, and a target opened in a browser is a web device.
922
+
923
+ **Breaking, with a migration path.**
924
+
925
+ - Delete the app's `ios/`, `android/`, `mobile/` folders and `capacitor.config.*`: `akan sync`, `akan doctor` and
926
+ `akan quality scan` now refuse them in an app root and say why.
927
+ - Move Capacitor settings to the keys above; `google-services.json` goes to `native.android.googleServices`, and
928
+ `GoogleService-Info.plist` is no longer needed.
929
+ - Removed: `-g/--regenerate`, `--open`, `--allow-provisioning-updates`, `configure-app`, `codepush`,
930
+ `release-source`, `capacitor.base.config`, `useContact`, `useCodepush`, the native path of `useSpeech`, and every
931
+ `@capacitor/*`, `@capacitor-community/*`, `@capgo/*`, `capacitor-plugin-safe-area`, `cordova-plugin-purchase`
932
+ and `@trapezedev/project` dependency — `akanjs` no longer lists them as peers.
933
+ - `baseSt` drops `deviceToken`; the notification store owns it.
934
+
935
+ - ccf2ae2: `native` in `akan.config.ts` replaces `mobile`, with each platform's settings in a section of its own
936
+
937
+ **Breaking:** `mobile` is gone with no fallback; a config that still has it, or a key that moved, stops and names the
938
+ new place. `native` and each of `native.targets` take the same fields, a target merging objects key by key and
939
+ replacing lists and every other value, and without `targets` the app has one target, `default`. The next `akan start`
940
+ removes the old `.akan/mobile`.
941
+
942
+ The agent guide `akan agent install` writes now covers native apps: the `native` section, the `start-*` / `build-*`
943
+ commands and updates, with the full contract in the `runtimeRule` guideline.
944
+
945
+ | Before | Now |
946
+ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
947
+ | `mobile` | `native` |
948
+ | `mobile.native.X`, `targets.<t>.native.X` (`plugins`, `ios`, `android`, `desktop`) | `native.X`, `native.targets.<t>.X` |
949
+ | `native.push.android` | `android.push` |
950
+ | `native.privacy` | `ios.privacy` |
951
+ | `assets.icon`, `assets.splash` | `icon`, `splash` |
952
+ | `files["ios/<path>"]` | `ios.files["<path>"]` |
953
+ | `files["android/res/…"]`, `files["android/assets/…"]` | `android.files["res/…"]`, `android.files["assets/…"]` |
954
+ | `deepLinks.ios.teamId` | `ios.teamId` |
955
+ | `deepLinks.android.sha256CertFingerprints` | `android.sha256CertFingerprints` |
956
+ | `indexPath` on a target only | on `native` too, inherited by every target |
957
+ | root and target `plugins` joined, Android XML lists appended | the target's list replaces |
958
+ | `.akan/mobile/<target>/native/<platform>` | `.akan/native/<target>/build/<platform>` |
959
+ | `.akan/mobile/<target>/{dev,updates,web,bin}` | `.akan/native/<target>/{dev,updates,web,bin}` |
960
+ | `AkanMobileConfig`, `AkanMobileTargetConfig` | `AkanNativeAppConfig` (resolved: `AkanNativeAppResult`), `AkanNativeTarget`; `AkanNativeSettings` is what `native` and a target share |
961
+ | `AkanMobileUpdatesConfig`, `AkanMobileAppId`, `AkanMobileTargetDeepLinks` | `AkanNativeUpdatesConfig`, `AkanNativeAppId`, `AkanNativeDeepLinks` |
962
+ | `AkanMobileNativeConfig` | `AkanNativeIosConfig`, `AkanNativeAndroidConfig`, `AkanNativeDesktopConfig` |
963
+ | `AkanMobileTargetAssets`, `AkanMobileTargetFiles` | removed: `icon`, `splash` and each platform's `files` |
964
+ | `MobilePermission`, `MobileEnv`, `AppConfigResult.mobile` | `NativePermission`, `NativeEnv`, `AppConfigResult.native` |
965
+
966
+ - ccf2ae2: feat(csr): only the current page publishes to the in-page agent, and a page can bind work to being looked at
967
+
968
+ A CSR page kept under the current one for a swipe back is mounted and live, so its `st.tool`s, resources, guides and
969
+ `st.use` keys used to sit in the agent's surface beside the current page's. The agent was offered a lever the user
970
+ could not see, and two pages declaring one name clashed.
971
+
972
+ - **`<AgentActivity active>`** (`use-agentic`) publishes what is registered below it only while `active`.
973
+ Registrations stay in place, so turning it back on re-publishes without a remount. A parked registration shadows
974
+ nothing and never clashes with a published one. Nested activities close with their parent. A registry kept outside
975
+ the surface reads the same gate through `useAgentGate()`. Under it are `AgenticSurface.gate(active, parent?)` and
976
+ `AgenticSurface.gated(gate)`, and the registration methods take the gate as an optional trailing argument.
977
+ - Every CSR page container renders one, open only for the current page. The store's live keys follow it, so
978
+ `readState` answers for the screen the user sees.
979
+ - The agent's screen targeting (`highlight`, a zone's `readScreen`, the cursor) skips anything under an `inert`,
980
+ `aria-hidden` or `hidden` ancestor, not only elements that carry one themselves.
981
+ - **`usePageFocusEffect(effect, deps)`** (`akanjs/webkit`) runs `effect` while the user is on the page and cleans it up
982
+ when they leave: after the entrance settles, and as soon as the page starts to go. It is for a camera, a poll or a
983
+ key binding that a live page under the current one must not keep running. **`usePageActivity()`** answers
984
+ `"current" | "prev" | "pending" | "hidden"`, and `current` outside a CSR stack.
985
+
986
+ - ccf2ae2: Push notifications go to each device token over the service that issued it — APNs or FCM — and a tap opens its
987
+ page from the app's first frame.
988
+
989
+ - `akanjs/client/native` exports `push`: permission, `register()` answering `{ token, provider, platform }`,
990
+ `unregister`, foreground presentation, and the `token`, `received` and `action` events. A tap that launched the
991
+ app is held until the first listener, and `akanjs` routes it from boot: an `action` whose `data.url` is an app
992
+ path opens it, and a foreground notification shows as a banner.
993
+ - `akanjs/client/capacitor` is gone.
994
+
995
+ Libraries synced into a workspace:
996
+
997
+ - `libs/util`: `usePushNotification` takes the native `push` plugin in a shell and Firebase on the web, and
998
+ answers a `PushToken` of `{ token, platform, provider, deviceId }`, where `deviceId` is a random id kept per
999
+ installation (`getPushDeviceId()`). `PushNotificationServer` reads `pushNoti.firebase` and `pushNoti.apns`
1000
+ (`teamId`, `keyId`, the `.p8` key's `privateKey`, `bundleId`, optional `environment`) and sends APNs itself
1001
+ over HTTP/2 with an ES256 provider token; a token APNs or FCM reports gone is dropped. Topics are gone.
1002
+ - `libs/shared`: `notiInfo.deviceTokens` holds one `DeviceToken` per installation (token, provider, platform,
1003
+ `deviceId`, `updatedAt`), replaced on re-registration and removed on sign-out (`signoutUser(pushDeviceId)`).
1004
+ An `all` notification fans out to active users in pages of 500 and honours each one's notification settings,
1005
+ which the old topic send did not.
1006
+
1007
+ **Breaking.** Stored plain-string tokens are dropped on read and each device registers again on its next visit.
1008
+ `addNotiDeviceTokenOfSelf` takes a `DeviceToken`, `subscribeToMegaphone` is gone, and a server sending to iOS
1009
+ needs `pushNoti.apns`.
1010
+
1011
+ - ccf2ae2: chore(deps): React 19.3 — `react`, `react-dom` and `react-server-dom-webpack` 19.3.0, `react-refresh` 0.19,
1012
+ `scheduler` 0.28
1013
+
1014
+ 19.3.0 ships the `pingSuspendedRoot` fix that `patches/react-dom@19.2.7.patch` carried, so the patch and the workspace
1015
+ template's copy are gone. The vendor shims name 19.3's new exports: `ViewTransition` and `addTransitionType` from
1016
+ `react`, `browser` from `react-dom`, and the whole `react-refresh/runtime` surface.
1017
+
1018
+ **For an existing workspace:** `akanjs` depends on `react` and `react-dom` 19.3.0 exactly, so move any workspace pin to
1019
+ 19.3.0 in the same install — two React copies break every hook. Drop `patchedDependencies["react-dom@19.2.7"]` from the
1020
+ root `package.json` and delete `patches/react-dom@19.2.7.patch`.
1021
+
1022
+ - 326da07: feat(document): query-level `remove`/`update` per filter, and rename `deleteMany` to `removeMany`
1023
+
1024
+ The model facade's `deleteMany(query)` and the store's `deleteManyByQuery(query)` never deleted anything: both
1025
+ stamp `removedAt` in one atomic UPDATE, exactly like `remove(id)` does. The framework has no hard delete for a
1026
+ model table at all — `DELETE FROM` appears only against the cache, `_akan_meta`, and the search mirror. They are
1027
+ now `removeMany` and `removeManyByQuery`, so the name matches the write, and `delete` stays free to mean a real
1028
+ `DELETE` if one is ever added. Rename the call sites; the behaviour is unchanged.
1029
+
1030
+ Filters now generate four query-level writes alongside the ten readers, on both the service and the model:
1031
+
1032
+ ```ts
1033
+ await this.removeInCategory("news"); // every match
1034
+ await this.removeOneInCategory("news"); // the newest match (createdAt desc)
1035
+ await this.updateInCategory("news").set({ status: "archived" }); // every match
1036
+ await this.updateOneInCategory("news").set({ status: "archived" });
1037
+ ```
1038
+
1039
+ **The update pair is a chain.** The patch cannot trail the filter args — those may be optional, and no tuple type
1040
+ puts a required element after an optional one — and leading it reads backwards. So it lands on a terminal `.set()`,
1041
+ which mirrors the `UPDATE … SET …` the call compiles to. Building the chain runs no query; only `.set()` does.
1042
+
1043
+ These are query-level writes: one atomic UPDATE that **fires no hooks**, so no `_pre`/`_postRemove` and no cascade
1044
+ run. Reach for them on a model that carries no removal side effect, and remove documents one at a time otherwise.
1045
+ `removeOne`/`updateOne` hit the **newest** match: their subquery is ordered `createdAt` descending, the caller cannot
1046
+ change that, and the result carries counts rather than an id. They are for "there is at most one of these", not for
1047
+ claiming the next item off a queue.
1048
+
1049
+ Because filter methods are assigned after CRUD, a filter keyed after its own model would have silently replaced
1050
+ the single-document `remove<Model>`/`update<Model>` with a hookless query-level one. That now throws while the
1051
+ service is being resolved instead. Services also gained `__removeMany`, `__removeOne`, `__updateMany`, and
1052
+ `__updateOne`, and the store gained `removeOneByQuery`.
1053
+
1054
+ On the **model facade**, `countDocuments(query)` is now `count(query)`; the old name still works and is marked
1055
+ `@deprecated`. Its writes keep `Many`/`One` spelled out — `updateOne` / `updateMany` / `removeOne` / `removeMany` —
1056
+ rather than following the short `find`/`findOne` pair: a bare `Model.update`/`Model.remove` would read like the
1057
+ document-path `update(id)` and `doc.remove()` while quietly hitting every match.
1058
+
1059
+ - 5f53462: feat(route): a route's head is JSX only, and analytics is the app's own component
1060
+
1061
+ **`.metadata()` is gone, along with the `metadata` / `generateMetadata` exports and the `AkanMetadata` type.** A route
1062
+ had two ways to say the same thing — an object of `title` / `description` / `openGraph` / `twitter` / `alternates`
1063
+ that the server translated into tags, and `.head()` taking the tags themselves — and could use only one of them per
1064
+ module. Every app in the workspace already wrote `.head(<>…</>)`. Rewrite an object as the tags it produced:
1065
+
1066
+ ```tsx
1067
+ .head(({ projectId }) => (
1068
+ <>
1069
+ <title>{`Project ${projectId}`}</title>
1070
+ <meta name="description" content="Project workspace" />
1071
+ <meta property="og:image" content="/og/project.png" />
1072
+ <link rel="canonical" href="https://example.com/project" />
1073
+ </>
1074
+ ))
1075
+ ```
1076
+
1077
+ A legacy route that still exports `metadata` or `generateMetadata` now fails the load as an unsupported export,
1078
+ the same way any other stray export does. The hreflang alternates Akan adds per locale are unchanged; the only thing
1079
+ that could suppress them was an `alternates.languages` object, so leave them out of the JSX.
1080
+
1081
+ **`.gaTrackingId()` and the `gaTrackingId` export / `System.Provider` prop are gone.** Which tags load, in which
1082
+ environment and behind which consent banner are the app's decisions, and the framework's built-in `Gtag` made none
1083
+ of them. Render a `"use client"` component that loads gtag.js from the root layout's `.render()` instead — the
1084
+ routing guide has one. GA4's Enhanced measurement already counts the router's `history.pushState` navigations, so
1085
+ the component only loads the tag.
1086
+
1087
+ The head-snapshot path used by the experimental partial commit (`AKAN_PUBLIC_RSC_PARTIAL_COMMIT=1` with
1088
+ `rscPatchHeadSafe`) was fed only by the metadata object, so it now always falls back to a full navigation.
1089
+
1090
+ - ccf2ae2: `AKAN_LISTEN_HOST` binds the server to one address instead of every interface, and `AKAN_ALLOWED_HOSTS`
1091
+ (comma-separated `host:port`) refuses any request, socket upgrade and preflight included, whose `Host` header it
1092
+ does not list, with 403. A page on a name that re-resolves to 127.0.0.1 (DNS rebinding) is same-origin with a
1093
+ loopback server, and `CrossSiteGuard` reads `Origin` equal to `Host` as same-site; only the `Host` header, never
1094
+ `x-forwarded-host`, still names that page's domain. Both apply to a solo server and to the gateway; a server behind
1095
+ the gateway skips the check, since the Host it sees is the gateway's hop.
1096
+
1097
+ Both are meant for a server only its own computer calls, as the server a desktop app carries is. A server that renders
1098
+ pages calls itself at `localhost:<PORT>` (SSR and its RSC worker), so a list that names only the public host refuses
1099
+ those calls with 403: put `localhost:<PORT>` in `AKAN_ALLOWED_HOSTS` too, and keep `AKAN_LISTEN_HOST` on an address
1100
+ that `localhost` reaches.
1101
+
1102
+ A CSR bundle reads `PUBLIC_AKAN_SERVER_URL` from the native shell's launch env (`__AKAN_NATIVE__.env`) ahead of the
1103
+ built-in `AKAN_PUBLIC_SERVER_URL`, for a server whose port is known only when the app starts.
1104
+
1105
+ - 69f7178: feat(signal): serve any app's signals to agents as an MCP server on `POST /mcp`
1106
+
1107
+ An endpoint already declares everything an agent tool needs — a name, typed arguments, a return model, guards, and
1108
+ a dictionary entry saying what it is for in two languages. What was missing was the wire. Every `*.signal.ts` can
1109
+ now be published as Model Context Protocol tools, resources, and prompts, with no per-endpoint adapter code.
1110
+
1111
+ Turn it on in an app's `main.ts` — `new AkanApp("./server", { mcp: { enabled: true } })`. Every field also has an
1112
+ env spelling (`AKAN_MCP`, `AKAN_MCP_READONLY`, `AKAN_MCP_PATH`, `AKAN_MCP_INSTRUCTIONS`, `AKAN_MCP_ALLOWED_ORIGINS`,
1113
+ `AKAN_MCP_LANGUAGE`, `AKAN_MCP_AUTH_SERVERS`, `AKAN_MCP_SCOPES`, `AKAN_MCP_RESOURCE`, …) because the gateway reaches
1114
+ a child process through its environment; a value written in code wins over the env of the same name.
1115
+
1116
+ **Nothing is exposed until it says so, slices included.** A guard stops a call, but the _name and argument schema_
1117
+ of something like `getAccessTokenByAdmin` are themselves worth hiding, and `tools/list` publishes both. Opt in per
1118
+ endpoint with `query(cnst.X, { guards: [...], mcp: { expose: true } })`, per slice with `init({ guards: [...],
1119
+ mcp: { expose: true } })`, and per generated CRUD verb with `slice(srv.x, { guards: {…}, mcp: { get: true } }, …)`
1120
+ — one flag per verb, never a blanket `true`.
1121
+
1122
+ **A tool runs the endpoint's own pipeline, not the service behind it.** `McpExecutionContext` extends the HTTP
1123
+ one, so guards, middleware, internal args, `hidden`/`secret` masking, and serialization all still run: an agent
1124
+ tool is exactly as safe as the endpoint it mirrors, and cannot be more permissive by construction.
1125
+
1126
+ **Refusals are fail-closed and survive opting in** — `pubsub` and `message` (their internal args read a socket an
1127
+ MCP request does not have), an `Any` or `Upload` return, a file upload, a mutation with no real guards, and a
1128
+ required `Any` argument. Each is named in the boot log beside `MCP catalogue: tools=… prompts=…`, along with every
1129
+ entry published with no description and every one published with no guards at all. `akan quality scan` covers the
1130
+ two shapes a source scanner can see, `akan.mcp.missing-description` and `akan.mcp.unguarded-exposure`, and the API
1131
+ explorer badges each endpoint `MCP` / `MCP refused` from the same shared predicate.
1132
+
1133
+ **Three protocol revisions from one stateless handler**: the modern `2026-07-28` and the legacy `2025-11-25` /
1134
+ `2025-06-18`. Supporting the legacy pair turned out to cost almost nothing once it was clear no session store was
1135
+ needed, and dropping it would have disconnected every client shipping today.
1136
+
1137
+ Other pieces:
1138
+
1139
+ - **Resources.** Generated reads publish `akan://<model>/{id}`, `akan://<model>/light/{id}`, `akan://<model>/list`,
1140
+ and `akan://<model>/list/<sliceKey>` alongside their tool. Those four shapes are the whole set — a custom
1141
+ endpoint keeps its tool and is named in the boot log rather than given a template that would resolve to someone
1142
+ else's endpoint.
1143
+ - **`prompt()`**, a fifth endpoint kind: the one a _user_ invokes by name, which a client renders as a slash
1144
+ command. Takes `.param()` and `.search()` only, returns `PromptMessage[]` built with `Msg.user` / `Msg.link` /
1145
+ `Msg.resource` / `Msg.image` and friends. Also mounted as a plain HTTP `GET` whether or not MCP is enabled, so a
1146
+ web UI can preview one — which is why it is in the OpenAPI document and why it must be guarded like any read.
1147
+ An embedded payload is masked by the model you name (`{ model: cnst.LightTask }`), taking the model as an
1148
+ argument so a `{ ...doc }` spread masks as correctly as a hydrated document.
1149
+ - **`McpProgress.report(n, { total, message })`** streams progress from anywhere inside a call — a service, an
1150
+ adapter, a loop several frames down — and is a no-op when nobody is reading, so the same code runs unchanged
1151
+ over HTTP, over a websocket, and in tests.
1152
+ - **Auth** is OAuth 2.0 protected-resource metadata plus a bearer check. A provably unusable token — expired, or
1153
+ audienced elsewhere — is refused up front rather than degraded to anonymous, because an anonymous caller is told
1154
+ a tool does not exist instead of being told to authenticate. A token carrying no `aud` is refused once
1155
+ `AKAN_MCP_AUTH_SERVERS` names an issuer and accepted while none is: that is the confused-deputy case RFC 8707 is
1156
+ a MUST for, whereas a first-party Akan token is already bound by app and environment.
1157
+ - **Descriptions come from the dictionary you already wrote.** The words an agent reads to pick a tool are the
1158
+ same `[en, ko]` pair the UI renders as a label, so an app that localizes well documents itself to agents for
1159
+ free. Generated entries borrow the model's `.of()` and `.desc()`, since none of them has text of its own.
1160
+ - `JsonSchemaBuilder` is now shared with the OpenAPI document via a `refPrefix` option rather than forked, and the
1161
+ exposure predicate lives in `akanjs/common` so the server catalogue and the browser API explorer cannot disagree.
1162
+ - `AgentCatalogue` (`akanjs/signal`) holds the audience-independent half — enumerating a signal registry, holding
1163
+ one name per entry, and resolving dictionary text — so a second agent transport reuses it rather than forking
1164
+ `McpDocument`.
1165
+
1166
+ Errors are reported as the caller's own where they are: a missing, unparseable, or undeclared argument and a
1167
+ document that is not there come back as `isError` naming it, and only a real failure logs a stack. An endpoint
1168
+ that did not opt in answers the same "unknown tool" as one that does not exist, and a guard's refusal never names
1169
+ the guard — the difference between those answers is what would enumerate the private surface.
1170
+
1171
+ - bf27773: feat(devkit): measure and enforce the server/client render split with `akan quality ssr`
1172
+
1173
+ Agent-written UI drifts client-side. The boundary conventions say which file role carries `"use client"`, but
1174
+ nothing said how _little_ should sit behind it, so markup that could ship as HTML kept landing in the bundle.
1175
+ `akan quality ssr` now reports the server render share per app and lib and flags the client code that should have
1176
+ rendered on the server; `akan quality scan` includes both, and `--format json` carries an `ssrBalance` field.
1177
+
1178
+ **The share is measured in JSX elements, not files.** A file count cannot move: the module convention already fixes
1179
+ `Zone`/`Template`/`Util` as client and `Unit`/`View` as server, so the ratio sits near 3:2 whatever an agent
1180
+ writes. Element mass is what actually shifts when rendering moves across the boundary.
1181
+
1182
+ Six rules under a new `ssr` warning scope:
1183
+
1184
+ - `akan.ssr.unnecessary-use-client` — the directive is present but the file uses no hook, event handler, store, or
1185
+ browser API.
1186
+ - `akan.ssr.client-static-component` — a component inside a client file renders markup with zero client-only
1187
+ capability.
1188
+ - `akan.ssr.client-static-markup` — a large subtree wraps one or two interactive touches.
1189
+ - `akan.ssr.client-mount-load` — a `useEffect(…, [])` loads server data the route could have fetched first.
1190
+ - `akan.ssr.module-missing-server-view` — a module renders only from `Template`/`Zone`/`Util` and declares no
1191
+ `Unit`/`View`.
1192
+ - `akan.ssr.template-client-state` — a `Template` holds form state in `useState` instead of the store.
1193
+
1194
+ Three exemptions keep the rules from firing on code that has no server alternative, and each one exists because the
1195
+ naive version was noisy in practice. A file importing a bare third-party package may need the directive for the
1196
+ package's sake, not its own — without this, chart, swiper, markdown, and editor wrappers dominated the output.
1197
+ `ui/<Folder>/index_.tsx` is the declared `lazy()` boundary. And only _mount-time_ loads count as findings: a
1198
+ `fetch.*` inside an `onClick` is an interaction, and a poll inside `useInterval` has no server equivalent, so
1199
+ neither is flagged.
1200
+
1201
+ The 50% server-share target is reported as a metric, not a warning. A scope can be legitimately client-heavy — a lib
1202
+ of third-party wrappers will never reach it — and a threshold that fires on unfixable code trains agents to ignore
1203
+ the tool, so the warnings stay individually actionable and the share stays a scoreboard.
1204
+
1205
+ Guidance ships with it: an **SSR First** section in the workspace `AGENTS.md` (and in the generated-workspace
1206
+ template) covering the cost model, the rule table, and a ten-point server-side playbook — client shells that render
1207
+ `children` untouched, `Tab`/`Tab.Panel` composition that keeps panel content server-rendered, `init`/`view`
1208
+ state-sync instead of mount fetching, passing an unawaited `ClientInit` promise so `Load.*` streams it behind a
1209
+ skeleton, named `ReactNode` slots, derived work on `Light<Model>`, server-side auth gating, and CSS variants in
1210
+ place of client state. The same content is fetchable as the `ssrRule` guideline (`akan guideline show ssrRule`,
1211
+ `get_guideline`) and mirrored into `.cursor/rules/ssr-first.mdc`.
1212
+
1213
+ - 5f53462: `akan subspace upload-env <name>` sends one subspace's env to the cloud workspace it deploys from
1214
+
1215
+ A subspace's `env/` values were the one thing the mirror had no way to move. Push holds them back and pull
1216
+ leaves them alone — deliberately, because the values belong to the repo that deploys — so getting a changed
1217
+ value into a customer's deployment meant running `akan upload-env` from somewhere that had both the values and
1218
+ that customer's `AKAN_WORKSPACE_ID`, which is nowhere: a workspace has every customer's values and exactly one
1219
+ id, its own.
1220
+
1221
+ `SubspaceDeclaration` now carries an optional `workspaceId`, the cloud workspace that subspace deploys from,
1222
+ and `akan subspace upload-env <name>` uploads against it. `akan.subspace.ts` is a workspace-only entry that no
1223
+ push ever ships, so declaring every customer's id there puts none of them in a customer's repo.
1224
+
1225
+ What is uploaded is the slice a push ships — the declared apps, the libraries their closure pulls in, and those
1226
+ apps' `secrets` globs — and nothing else. The whole workspace would be the simpler thing to send, and it is the
1227
+ wrong one: an env archive is replaced whole on arrival, so one customer's cloud workspace would end up holding
1228
+ every other customer's secrets. `CloudRunner.gatherEnvFiles` takes that scope and an archive path (the slice
1229
+ lands in `local/env.<name>.tar`, never over the full `local/env.tar`), while the `akan:secrets` block it
1230
+ maintains in the root `.gitignore` keeps being written from every app in the workspace — narrowing that file to
1231
+ one slice would silently un-ignore every other app's secrets.
1232
+
1233
+ Because the upload replaces an archive in a repo you are not standing in, three things are refused rather than
1234
+ guessed: a subspace with no `workspaceId`, a subspace declaring this workspace's own id (a slice uploaded there
1235
+ would leave the workspace's next `download-env` short of every other app), and two subspaces declaring the same
1236
+ id. The command asks for confirmation before it replaces anything, naming the subspace and the cloud workspace;
1237
+ `-F, --force` skips the question, and with no terminal to ask in the command refuses instead of assuming yes.
1238
+ (`-f` is already `--format` and `-y` already `--verify`.)
1239
+
1240
+ The direction is one-way on purpose. There is no `subspace download-env`: it would write a customer's values
1241
+ over the workspace's own copies, and which of the two is right is not something a command can know.
1242
+
1243
+ - 5f53462: The page shows what the agent is doing to it
1244
+
1245
+ An in-page agent's work used to be legible only in the transcript, and the transcript is behind a panel that is
1246
+ closed as often as it is open. A form that fills itself, a tab that switches, a route that changes — each arrived
1247
+ with nothing anywhere attributing it. Now the page itself says so, from the one signal that knows a call is
1248
+ running rather than from a store action, which cannot tell the agent's write from the user's.
1249
+
1250
+ Two effects, both on by default. **The control a call was published from is ringed** where it stands, scrolled to
1251
+ first when it is off screen — no app code, because the same `onChange={st.do.setTitleOnTask}` reference that earns
1252
+ `data-akan-action` is what makes the control findable. And **a pointer travels to it and presses it**: the ring
1253
+ answers _where_, the pointer answers _who_. It glides only when the hop is far enough to be worth following,
1254
+ presses on arrival rather than on departure, and fades when the batch is over.
1255
+
1256
+ A call that reaches no control draws nothing, `navigate` included — the router is not an element.
1257
+
1258
+ The form patch fans out: `fill<Model>Form` rings one control per field it named, resolved through the same
1259
+ `data-akan-state` the setter annotates, capped at five — a patch of twenty is a form being filled, not twenty
1260
+ events.
1261
+
1262
+ `visual` on `Agent.Chat` and `Agent.Zone` turns it off (`false`) or turns one effect off
1263
+ (`visual={{ cursor: false }}`). Nothing is ever waited on: a call starts the moment its event is handed over.
1264
+
1265
+ A tool a name several rows answer to rings nothing rather than guessing a row, a call an approval or a guard
1266
+ turned back is never drawn, and a batch past four calls stops scrolling and keeps ringing.
1267
+
1268
+ `ToolRunner` gained an `activity` host channel and `AgentSession` an `onActivity` option, both carrying
1269
+ `ToolActivity`. They are separate from `progress`, which only ever fires for a tool that chose to report — and a
1270
+ tool that says nothing about itself is exactly the one whose effect arrives unexplained.
1271
+
1272
+ **`highlight` now waits for its scroll.** `ScreenFlash` — the extracted reveal-and-ring both effects share — fixes
1273
+ a settle loop seeded with `NaN`, which made every first-frame comparison false and put the ring on at the moment
1274
+ the smooth scroll _started_. On a long page it had already faded by the time the user's eye arrived.
1275
+
1276
+ - ccf2ae2: The volume plugin reads and sets Android's media volume
1277
+
1278
+ On Android, `volume` from `akanjs/client/native` drives the media volume (STREAM_MUSIC), the one the volume keys move
1279
+ while the app plays: `getVolume()`, `setVolume({ level })`, `setMuted({ muted })` and the `change` event behave as on
1280
+ the desktop. Setting the level keeps the mute as it was (Android unmutes a stream whose volume changes), and a device
1281
+ with a fixed volume answers `settable: false`. iOS still answers `UNSUPPORTED`.
1282
+
1283
+ - ccf2ae2: `trustedDependencies` in `akan.config.ts` names the packages whose install scripts run where the app is installed
1284
+
1285
+ `bun install --production` skips every dependency's install and postinstall scripts unless the package is trusted, so
1286
+ a native addon that builds itself at install time (one that ships no prebuilds) reached the image unbuilt and failed
1287
+ at its first call. An app or a lib now lists those packages in `trustedDependencies`; the built `package.json`
1288
+ carries the list, and both the image and a desktop app's server run their scripts. A lib's list reaches every app.
1289
+
1290
+ - ccf2ae2: `usePurchase` sells through the native in-app purchase plugin: StoreKit 2 on iOS, Play Billing on Android.
1291
+
1292
+ - `usePurchase({ platform, productInfo, url, onPay, onSubscribe })` answers `{ isLoading, products,
1293
+ purchaseProduct, restorePurchases }`. `purchaseProduct(product | id, offerToken?)` resolves `"purchased"`,
1294
+ `"pending"`, `"cancelled"` or `"unverified"`; a shell on a platform the app does not sell in asks the store
1295
+ nothing, and the web sells nothing.
1296
+ - A transaction is verified by `POST <url>/billing/verifyBilling` with `{ data }`, handed to `onPay` or
1297
+ `onSubscribe`, and only then finished (a consumable consumed, anything else acknowledged). A refused
1298
+ verification or a failing callback leaves it unfinished, so the store hands it over again; what the last launch
1299
+ left unfinished is settled at mount, and one transaction is never credited twice.
1300
+
1301
+ **Breaking.** The hook's options and answer changed, and `cordova-plugin-purchase` is no longer a peer. iOS sends
1302
+ the transaction's JWS alone as `receipt` (no app receipt, no `accountId`), so the verification server has to accept
1303
+ the App Store Server API form in the same release.
1304
+
1305
+ - 5f53462: Web push has one owner, and it draws once
1306
+
1307
+ `/firebase-messaging-sw.js` was a framework route that built the worker in memory from `env.client.ts` on every
1308
+ request. It matched ahead of the static fallback, so an app's own `public/firebase-messaging-sw.js` was never
1309
+ served and there was no way to turn it off — including the one the push plugin's `syncAssets` had already written
1310
+ there, which meant editing that file changed nothing a browser ever saw. The route is gone. The plugin owns the
1311
+ worker, `akan sync` writes it, and the static fallback serves it.
1312
+
1313
+ Because the plugin writes a file rather than answering a request, it now rewrites a worker whose body has changed
1314
+ instead of skipping any file that exists — otherwise a fix here would never reach an app that had synced once. The
1315
+ file opens with a banner; remove that line and sync leaves the file alone, which is how an app takes the worker
1316
+ over.
1317
+
1318
+ The worker itself had four bugs, all of them the kind that only show up with a real device in hand:
1319
+
1320
+ - **It never activated on time.** With no `install`/`activate` handlers a visitor who already had a worker got the
1321
+ new one on the visit _after_ their next one. It now calls `skipWaiting()` and `clients.claim()`.
1322
+ - **One push arrived as two notifications.** firebase-js-sdk draws a payload carrying a `notification` block
1323
+ itself and _then_ calls `onBackgroundMessage`, so drawing there unconditionally doubled it — visible as a
1324
+ duplicate without a `tag`, and as a re-alert with one. The handler is now data-only.
1325
+ - **Every click opened a new tab**, and a full reload rather than a route change. It now focuses an open window
1326
+ and hands the path over; `openWindow` is the fallback, not the default.
1327
+ - **A badge count in `data.badgeCount` went nowhere.** The worker applies it through `setAppBadge`, guarded —
1328
+ Firefox and desktop Safari ship no such method and an unguarded call is a synchronous `TypeError`.
1329
+
1330
+ `AkanSyncContext` gained `readFile`, the pair of the `fileExists`/`writeFile` it already had, so a plugin can tell
1331
+ what it is about to overwrite.
1332
+
1333
+ The app-side half ships in `libs/util`, which is where the worker and the hook now live. FCM hands a payload to
1334
+ the page instead of the worker whenever a visible window client exists, and `usePushNotification` listened only in
1335
+ the worker — so a push sent while the app was on screen was dropped outright, with FCM reporting the send as
1336
+ delivered, which is what makes that one expensive to diagnose. The hook now installs `onMessage` and draws through
1337
+ the worker's registration, receives the worker's click handover, and routes it through `router.enterDeepLink`.
1338
+
1339
+ - ccf2ae2: A Windows desktop release is signed with Authenticode
1340
+
1341
+ With `AKAN_NATIVE_WINDOWS_CERTIFICATE` + `_CERTIFICATE_PASSWORD` (a `.pfx`), `AKAN_NATIVE_WINDOWS_THUMBPRINT` (a
1342
+ certificate in the store) or `AKAN_NATIVE_WINDOWS_SIGN_COMMAND` (a JSON array run once per file with `{file}`, for Azure
1343
+ Trusted Signing or a cloud HSM), `akan build-desktop` signs every PE file of the app — the executable, its DLL, the
1344
+ server's addons and `bin` — SHA-256 with an RFC 3161 timestamp (`AKAN_NATIVE_WINDOWS_TIMESTAMP_URL`), and verifies them.
1345
+ `--installer` signs the setup program and, through makensis's `!uninstfinalize`, the uninstaller it writes; the
1346
+ settings reach the signer through the environment only. `publish-update` signs a Windows release the same way, and an
1347
+ unsigned release build warns that SmartScreen flags a downloaded copy.
1348
+
1349
+ ### Patch Changes
1350
+
1351
+ - ccf2ae2: A CSR page or layout whose async render throws says so, a cached page whose layout redirected renders again, and a
1352
+ dev client module that imports a macro builds.
1353
+
1354
+ - An async render that rejected used to leave its layer blank with nothing in the console, since the promise's
1355
+ failure was dropped. It is now logged with the stack and the layer it belongs to (`render of page 2 of
1356
+ /:lang/home failed: …`).
1357
+ - `akan:debug:frame` takes `memory` besides `1`: the frame trace goes to `window.__AKAN_FRAME_TRACE__` (the last
1358
+ 2000 events) instead of the console. A native dev build mirrors each console call over the bridge, which slows the
1359
+ frame enough to hide a timing bug; `memory` does not. Async page and layout renders add `layer.render`,
1360
+ `layer.settle` and `layer.fail` events.
1361
+ - A cached page whose layout redirected renders that layout again when it is back on screen. Signed out, a
1362
+ layout that sends the person to the sign-in page kept its empty result, so the page stayed blank after the sign-in
1363
+ brought them back (a native app opens its home first, so it always did). A render that issued a redirect while it
1364
+ ran is now rerun the next time its page becomes current; `router.redirectCount()` is what it compares.
1365
+ - `window.__AKAN_DUMP_FRAME__()` reports the frame's location, stack and phase and every recent async page and
1366
+ layout render: settled or pending, still awaited, drawn. It records only the latest state, so it does not change
1367
+ the timing a trace would.
1368
+ - `akan start` no longer fails a dev client build with `ReferenceError: __akanMeta is not defined` when a file
1369
+ imports a macro (`useClient.ts`'s `with { type: "macro" }`) whose modules read `import.meta` at the top level.
1370
+ Bun applied the dev build's `import.meta` rewrite to the macro's modules too, and ran them where nothing declared
1371
+ the rewritten name; such a file now has its macros run by a build of its own first.
1372
+
1373
+ - ccf2ae2: A native dev build's API calls and sockets go through the dev gateway, so a phone on the same network — or the
1374
+ simulator, the emulator and a desktop app — reaches `akan start` without a reversed port.
1375
+
1376
+ - A page the gateway served calls its own origin: `getEnv().serverHttpUri` is `<page origin><api prefix>` and the
1377
+ websocket follows it, unless `AKAN_PUBLIC_SERVER_URL` names a server. A release build is unchanged.
1378
+ - The iOS, Android and desktop hosts carry the method, the body and the request headers, `authorization`
1379
+ included, to the gateway; the gateway relays the app's `<prefix><websocketPrefix>` socket. Android hands the
1380
+ host no request body, so a call that carries one goes to the gateway's `http://localhost` origin directly and
1381
+ the server answers its CORS.
1382
+ - A desktop dev build opens the target's start page instead of `/`, and builds when the web root has no
1383
+ `index.html` yet.
1384
+ - `NativeWebDir` lists the web root in `/`-separated paths on Windows too.
1385
+
1386
+ - 5f53462: A relation field renders the value it holds
1387
+
1388
+ `Field.Parent` / `ParentId` / `Children` / `ChildrenId` built their options out of the slice list alone, and that
1389
+ list is loaded when the dropdown opens. A form filled by `edit<Model>` therefore rendered an empty control over a
1390
+ value it was holding — and opening the dropdown did not always fix it, because a slice list is one `limit`-sized
1391
+ window and the row the value points at can sit outside it.
1392
+
1393
+ `Select` was the immediate cause: a selected value with no matching option rendered nothing at all, `renderSelected`
1394
+ included, so no amount of care in the caller could have painted it. It now renders the value it is given, which is
1395
+ what a controlled component owes its parent — an enum that lost a member shows the stale value rather than a blank
1396
+ field.
1397
+
1398
+ The four controls now merge what they hold into their options. A `Light`-valued control already carries the row, so
1399
+ it needs no request and the value is right in the server render. An id-valued control carries no row at all, so a
1400
+ missing one is read through the generated `light<Model>` endpoint and cached per `refName:id` — deliberately beside
1401
+ the store rather than in it, because a model store is a singleton and resolving into it would overwrite whatever
1402
+ listing of the same model the screen is already showing. A row that cannot be read — removed, or behind narrower
1403
+ `get` guards than the slice the control lists from — is remembered as unreadable rather than retried, and the
1404
+ control shows the id.
1405
+
1406
+ Four things came with it, all of them the same bug seen from another side:
1407
+
1408
+ - Opening the dropdown no longer invalidates the slice list. It refreshed on every open, replacing the rows any
1409
+ `Load.Units` on the same page had put on screen; it now reuses what the store already loaded.
1410
+ - `Field.ParentId` offered its options as bare ids, so `searchable` matched on the hex id while the option on
1411
+ screen read as a name. Every control now labels its options — from `renderOption` when that returns a string,
1412
+ otherwise from the model's own `labelOf`.
1413
+ - `sortOption` was accepted by all four and applied by none.
1414
+ - `Select` takes `loading`, and the controls pass the slice's loading flag, so an opened dropdown says it is
1415
+ fetching instead of claiming there is nothing to pick.
1416
+
1417
+ - ccf2ae2: fix: a gateway's replica takes the websocket relay on 127.0.0.1, from the gateway only
1418
+
1419
+ Behind the gateway a replica listens for relayed sockets on a TCP port of its own, and its ready message names
1420
+ 127.0.0.1, where the gateway dials. It bound that port on every interface; it now binds 127.0.0.1 only, whatever
1421
+ `AKAN_LISTEN_HOST` names, and answers only the Host the gateway's hop carries, so a page on a name rebound to
1422
+ 127.0.0.1 is refused there too.
1423
+
1424
+ - ccf2ae2: fix: a server whose parent dies while it boots exits instead of serving on
1425
+
1426
+ A replica (and a desktop app's carried server) listened for its parent's IPC channel closing only after its `init`
1427
+ finished, and Bun drops that event for a listener added after a `message` listener, which the single-mode queue and
1428
+ pub/sub add during `init`: a gateway or shell killed during boot left the server running, holding its port and its
1429
+ database, with its cron jobs. It now listens before `init`, treats a channel already closed or a `ready` it could not
1430
+ send as a parent gone, and ends its process group the same way it does after boot.
1431
+
1432
+ - 5f53462: fix: a navigation to a route that is not there leaves the page alone
1433
+
1434
+ The RSC route answers a target that resolves to nothing with `0:null` under a 404 — a Flight payload whose root is
1435
+ literally `null`. `fetchRscNavigationResponse` read the redirect and patch headers but never the status, so that
1436
+ payload was decoded like any other and committed: the client replaced the whole document with an empty tree. Since
1437
+ the tree is mounted by `hydrateRoot(document, …)`, everything went with it — the root layout, and whatever it holds,
1438
+ an in-page agent's chat session included. A full document load never showed this, because that path renders a real
1439
+ not-found page instead; only client navigation reached the empty one, so the same bad href behaved completely
1440
+ differently depending on how it was followed.
1441
+
1442
+ The status is now read before the body reaches the decoder, and a not-found navigation is **refused rather than
1443
+ committed**: nothing is rendered, history is never touched, and the page stays exactly where it was. It is
1444
+ deliberately not converted into a document navigation either — that would land on the 404 this refusal exists to
1445
+ avoid. `Router.navigation()` is the new report: it answers the navigation `push` / `replace` last started and
1446
+ rejects when the route refused to move, which `push` itself cannot do because it returns before the payload for the
1447
+ new route has even been asked for. `popstate` is the one exception and goes the other way — the address bar has
1448
+ already moved, so refusing would leave the tree and the URL describing different pages, and a document navigation
1449
+ there renders the real not-found page.
1450
+
1451
+ The in-page agent's `navigate` reads that report and answers the model with the miss instead of a move that did not
1452
+ happen, so a guessed path costs one tool error on the screen the agent is still on rather than the session.
1453
+
1454
+ - ccf2ae2: fix: a file `getBlob` serves never runs as a page of the API's origin
1455
+
1456
+ `libs/util`'s `getBlob` (`/api/localFile/getBlob/*`) sets no Content-Type of its own: Bun types the body by its
1457
+ stored name and answers a Range with a 206, and the response goes out the way Bun sends a file, neither buffered nor
1458
+ compressed. Every answer but a PDF's carries `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline';
1459
+ sandbox` and `X-Content-Type-Options: nosniff`, so an uploaded `.html` or `.svg` opened from its URL runs no script on
1460
+ the API's origin, which is also the origin of a desktop app's carried server. An `<img>` or `<video>` showing the file
1461
+ is unaffected; a PDF is left out because a browser's viewer refuses a sandboxed document.
1462
+
1463
+ - 69f7178: fix: make the admin panel's own controls work, and give the data surface a look
1464
+
1465
+ `Model.AdminPanel` is the one component an app mounts to get a model's whole admin surface, and most of what it
1466
+ put on screen did nothing.
1467
+
1468
+ **The dashboard crashed the panel on every app that has no `summary` state.** `AdminPanel` always passed a
1469
+ `renderDashboard`, and `ListContainer` read the tiles it needs through `st.use.summary()` — an app-level store key,
1470
+ not a generated one — so the accessor was `undefined` and calling it took the route down. It now reads the key off
1471
+ the state through `st.sel`, which is one hook whether or not the key exists, and `AdminPanel` defaults a dashboard
1472
+ only when `summaryColumns` names something to put in it.
1473
+
1474
+ **A summary tile never rendered even where the state existed.** `AdminPanel` declared a `queryMap` prop and passed
1475
+ `{}` in its place, and `Dashboard` skipped every column the map did not name. The map is forwarded, and it now
1476
+ decides whether a tile _links_ to its filtered listing rather than whether the tile exists at all. The tile is also
1477
+ no longer an `<a>` inside a `<button>`.
1478
+
1479
+ **The sort selector was permanently empty.** It read `fetch.<model>SortKeys`, which nothing defines, and labelled
1480
+ the options with a dictionary key one level short of the real one. Sort keys now travel with the serialized signal
1481
+ (`FetchSerializer` emits `filter.sortKeys`, which `FetchClient` already knew how to merge and register), the
1482
+ selector reads `fetch.sortKeyMap`, and a label that misses the dictionary falls back to the humanized key instead of
1483
+ printing `user.latest` at the reader.
1484
+
1485
+ **`query`, `init`, and `sort` were dead props.** The init effect called `init<Model>()` with no arguments at all.
1486
+ It now fills every slice argument before the init form, which is the only order the generated action reads
1487
+ correctly — passing the form into an unfilled positional slot made it the query.
1488
+
1489
+ Also: an `Export CSV` that emitted tab-separated JSX (`[object Object]` for any column with a renderer) is real,
1490
+ escaped, BOM-prefixed CSV read from the column's `value`; a card column with no `.length` — every number, every
1491
+ date — renders instead of silently vanishing; a table header with no dictionary entry reads `Created At` instead of
1492
+ `user.createdAt`; an empty card list says so instead of rendering nothing; a table keeps its rows while refreshing
1493
+ instead of flashing its empty state; and the toolbar, tiles, and cards are rebuilt on the semantic tokens with a
1494
+ card/table toggle, so the panel looks like the rest of the framework.
1495
+
1496
+ - 69f7178: feat!: `setAgentAccess` takes guard classes, not a policy function
1497
+
1498
+ Who may spend the LLM key through the `runAgentTurn` relay is an authorization decision, and this codebase spells
1499
+ those as guard classes everywhere else. `AgentRelayAccess` now forwards to the guards an app names instead of
1500
+ calling a bespoke `(context) => boolean` — so the relay reuses the same `srvkit/guards.ts` classes an endpoint's
1501
+ own `guards: [...]` array takes, rather than making the app restate `!!context.get("account")` in a shape nothing
1502
+ else in the framework understands.
1503
+
1504
+ ```ts
1505
+ // before
1506
+ option.setAgentAccess((context) => !!context.get("account"));
1507
+ // after
1508
+ option.setAgentAccess(SignedIn);
1509
+ option.setAgentAccess([SignedIn, NotBanned]); // ANDed, as an endpoint's own array is
1510
+ ```
1511
+
1512
+ `null` still clears what a library set, and with nothing named the call is still refused like `None`. The
1513
+ `AgentRelayPolicy` type is gone. `AgentRelayAccess.scope` became a getter reporting whatever its delegates need,
1514
+ so a resource-scoped guard cannot be evaluated argument-free by a catalogue that believed the old static
1515
+ `"account"`. Guard instances are now cached by `guardOf` in `signal/guard.ts`, which `SignalContext` uses too —
1516
+ one instance per class, built on first use rather than at registration.
1517
+
1518
+ - 69f7178: feat: the in-page agent can ask the user instead of guessing
1519
+
1520
+ `askUser` is a fourth built-in on every turn, and the **session** owns it rather than the surface: the answer comes
1521
+ from the conversation, not the screen, so it needs no declaration from the page and a zone agent asks inside its own
1522
+ transcript. `AgentSession` parks the loop on `pendingQuestion` exactly as it parks on `pendingApproval`, and
1523
+ `Agent.Chat` renders the question card above the composer — which is closed anyway while a turn runs, so the card is
1524
+ the only way in.
1525
+
1526
+ `choices` offers a pick (`multiple` for several) and omitting them asks for free text. The card keeps a free-text row
1527
+ either way, because the model wrote the options and only the user knows whether the right answer is among them.
1528
+ Dismissing is the tool's **error** result rather than a silent empty answer, so a model that asked cannot read a
1529
+ skipped question as consent; an aborted turn settles the same way. A question with no text is refused without ever
1530
+ reaching the screen, and choices are trimmed and deduped because the answer is the option's own text — two identical
1531
+ options cannot be told apart.
1532
+
1533
+ A settled ask renders as the exchange it was, question then answer, instead of as a `askUser` tool row: while the
1534
+ question is pending only the card holds it, so the text is never on screen twice. A hook tool named `askUser` shadows
1535
+ the built-in like any other, and the relay needed no change — the tool rides the same wire the surface's own do.
1536
+
1537
+ - 5f53462: An attachment carrying both bytes and an address is read from the bytes
1538
+
1539
+ Both vision adaptors picked `url` over `data` — `AnthropicLlm.sourceOf` and the OpenAI dialect's image part — so a
1540
+ host could not send an address for the screen and bytes for the model in one attachment. Sending both got the
1541
+ address, and when that address was private the model answered about a picture nothing had fetched, with no error
1542
+ anywhere. That is the one attachment failure that reports nothing, and the framework's own default storage backend
1543
+ serves exactly such a path.
1544
+
1545
+ The order is now bytes first in both. Choosing the address does save the provider hop what the bytes weigh, so this
1546
+ is not free — but a host that has a publicly reachable URL has no reason to also inline the bytes, while a host
1547
+ that uploaded to private storage has every reason to send both. Being wrong the new way costs a larger request;
1548
+ being wrong the old way cost a confident answer about nothing. A provider-reachable URL should still travel alone.
1549
+
1550
+ - 5f53462: An attachment can carry the host's own handle on the file, and the composer's ceilings are the app's
1551
+
1552
+ `MessageAttachment.ref` is opaque to the framework, which only moves it — a file id, a storage key, whatever turns
1553
+ the attachment back into something a tool can be handed. Without one, a host that stores its uploads keeps a map
1554
+ beside the transcript keyed on name and size, which is the guess `Attachment.same` has to make and the one that is
1555
+ wrong for two crops of one export; when both sides carry a `ref`, that is now the answer instead.
1556
+
1557
+ The per-file ceiling is measured on what `attach` produced rather than on the file that was picked, so a reader
1558
+ that uploads and answers a `url` is no longer refused for a cost it does not incur. All three ceilings become
1559
+ defaults behind `<Agent.Chat attachLimits={{ perFileBytes, perMessageBytes, perMessageCount }} />`, since what one
1560
+ request can carry belongs to the configured provider.
1561
+
1562
+ A `url` the reader answers is handed to the provider as the address it will fetch, which is now documented on
1563
+ `attach`: the default storage backend serves a path only the app can resolve, and a model given one answers about
1564
+ a picture it never saw with nothing anywhere reporting a failure. Answer `data` when the provider cannot reach it.
1565
+
1566
+ - 5f53462: An attachment an app built itself cannot kill the turn
1567
+
1568
+ `session.send([{ role: "user", text, attachments }])` lets a host assemble attachments directly, and until now
1569
+ `MessageAttachment` was not exported from `akanjs/ui` — so the one path where the app writes the object was the one
1570
+ path with no type to write it against, while the composer's own reader was typed. It is exported now.
1571
+
1572
+ Two places trusted that object's declared shape. `AgentService.isReadable` dereferenced `mimeType` unguarded, so a
1573
+ missing one threw out of the relay and took the whole turn — every other attachment of the message with it — under
1574
+ a `TypeError` that named nothing; it is now unreadable like any other type the provider cannot take, which routes
1575
+ it through the note that names the file. The attachment chip did the same on the preview branch, where the throw
1576
+ took the transcript's render down. Both now cost that one attachment and nothing else.
1577
+
1578
+ `field.hidden` is documented as the trap behind this: it is stripped from every endpoint response and hydration
1579
+ writes `null` over the key, while the generated type still declares the field — so a client holds a deliberate
1580
+ `null` behind a type that promises a value, and only `??` / `== null` catch it.
1581
+
1582
+ - 5f53462: feat: the in-page agent issues independent tool calls in one turn instead of one per turn
1583
+
1584
+ A turn has always been able to carry several tool calls — the session collects every `toolCall` event, runs them in
1585
+ order and posts them back as one `tool` message — but nothing asked the model to use it. A screen that needed ten
1586
+ calls got ten turns, each paying a full model round trip and a resend of the whole transcript, and the turn cap
1587
+ then parked the run on a "keep going?" card halfway through.
1588
+
1589
+ - `AgentService.preamble` is the framework's own half of the system prompt, ahead of whatever the app declared. It
1590
+ asks for the batch — every call that does not need another call's result goes in the same turn, and a tool that
1591
+ does the whole job in one call (a form's fill tool) beats one call per field — and it asks the model not to read
1592
+ the screen back to confirm work it has just done, since the change report it was handed already says so. Both
1593
+ sentences were measured against the provider: "approve these eight" went from 1.5 turns (with three runs in eight
1594
+ doing nothing at all) to one turn in every run, and "read the screen, then approve what is pending" from 3.0
1595
+ turns to its floor of 2.0 in every run. The read a fresh route needs after `navigate` is acquisition rather than
1596
+ confirmation, and survived in every run of that scenario. It is composed in the service rather than in an adaptor
1597
+ for the reason `explained` is — an adaptor that has to remember it is one that forgets.
1598
+ - The turn cap defaults to 12 assistant turns instead of 8. A chain of ten calls could not finish under the old
1599
+ default without the user answering a question first.
1600
+ - `readState` and `highlight` declare `settle: false`, as `readScreen` already did. A settle is 120ms of DOM quiet
1601
+ at the very least, and neither of them changes anything a resource holds, so ten reads paid a second and a bit
1602
+ for a change report that is empty by construction.
1603
+
1604
+ - 5f53462: feat: a tool the user answers, rendered in the chat by the app that declared it
1605
+
1606
+ `st.tool("collectContact").desc("…").card(({ submit, cancel }) => <Contact.Form …/>)` is a second way a tool chain
1607
+ ends. Where `.exec()` runs a function, a card parks the call in the chat and renders the app's own component there;
1608
+ what the form submits is the call's result, and cancelling is the error the model reads instead. A name and a phone
1609
+ number, a date somebody has to look up, a signature — those are answers a model must not invent, and until now the
1610
+ only surfaces a chat could park on were the approval gate and `askUser`'s question, neither of which carries a
1611
+ shape.
1612
+
1613
+ `AgentSession` holds it as `pendingCard` beside `pendingApproval` and `pendingQuestion`, and `Agent.Chat` renders it
1614
+ in the same place; `AgentToolCard` is an `_overrides.tsx` slot like the other two. The frame draws its own way out
1615
+ even when the app's component does not, so a turn can never park on something the user cannot dismiss.
1616
+
1617
+ The call waits **outside** the tool queue, for the reason an approval does: a form parked in front of somebody is
1618
+ not work, and holding the execution lock across it would freeze every other agent on the page behind one unanswered
1619
+ card. The declared arguments are checked before the card is parked rather than while it renders — a throw inside the
1620
+ host's tree would take the chat down, where a verdict reaches the model as something it can correct — and `confirm`
1621
+ is not read for a card at all, because the card is already the asking. The screen is still snapshotted around the
1622
+ wait, so a card that writes what it collected into a store reports what moved like any other call.
1623
+
1624
+ - f882bbc: feat: the in-page chat is customizable per part, opens from the app's own control, and composes multi-line
1625
+
1626
+ **Eight slots replace what the chat renders, so a skin no longer means replacing the panel.** `AgentLauncher`,
1627
+ `AgentBubble`, `AgentComposer`, `AgentApproval`, `AgentQuestion`, `AgentMenu`, `AgentMarkdown` and `AgentCode`
1628
+ each bind in `_overrides.tsx` beside the existing `AgentChat`, and `akanjs/ui` exports every default next to them
1629
+ (`DefaultBubble`, `DefaultComposer`, …) so a replacement composes the one it is replacing instead of
1630
+ re-implementing the loop, the slash commands and the approval gate. `AgentCode` is the seam a highlighter binds
1631
+ to — the markdown scanner now keeps the fence's language, which it used to drop. A component bound to
1632
+ `AgentBubble` carries its own `memo`: the transcript re-renders on every streamed delta.
1633
+
1634
+ **The panel takes a controlled `open`/`onOpenChange` pair, so an app opens the chat from its own control.**
1635
+ Conditional mounting was not a substitute — unmounting aborts the session and discards the conversation.
1636
+ `launcher={false}` draws no launcher for an app that has its own entry point, `launcherClassName` /
1637
+ `panelClassName` reach one surface each where `className` reaches both, `intro` replaces the empty-state line
1638
+ (where starter questions go) and `header` adds controls beside the built-in clear and close.
1639
+
1640
+ **The composer is a textarea**: Enter sends, Shift+Enter writes a newline, and it grows to a few lines before it
1641
+ scrolls. The vertical arrows still walk what was sent, but only from the first or last line — anywhere else the
1642
+ caret belongs to the textarea. It resolves its field shell through the `input` recipe slot, so a recipe swap
1643
+ reaches it like every other field.
1644
+
1645
+ **On a phone the panel is the whole screen**, a card from `sm:` up, and it lifts above the on-screen keyboard —
1646
+ only `visualViewport` reports that inset, and a full-screen chat whose composer sits under the keyboard is one
1647
+ nobody can type into.
1648
+
1649
+ `SessionContext`, `useAgent`, `agentSessionOf`, `ChatCommands` and the parts' prop types are exported too: an app
1650
+ may not import `use-agentic`, and without them a replacement could not see the session an `Agent.Zone` handed down.
1651
+
1652
+ - 5f53462: The in-page chat takes the next message while a turn is still running.
1653
+
1654
+ - Enter (or the Queue button that appears beside Stop) during a turn parks the message and sends it the moment the
1655
+ turn ends, instead of doing nothing. One slot: a second send joins the first on a new line, so the model is
1656
+ handed one user message. Staged files ride along, under the same per-message ceilings.
1657
+ - The parked message shows on a card above the composer with two ways out — take it back into the composer to
1658
+ change it, ahead of whatever was typed since, or drop it. The card is the `AgentQueued` slot, with `DefaultQueued`
1659
+ and the `QueuedMessage` type exported beside the other chat parts.
1660
+ - Stop hands a parked message back to the composer rather than opening the next turn with it, so Stop means stop.
1661
+ `/new` drops it along with the conversation, the way it drops staged files. A `/prompt` parks like text; the
1662
+ built-in commands never park, and a pending question still takes the composer as its answer.
1663
+ - A spoken ask parked behind a turn is still the one answered out loud — and a second voice ask no longer re-reads
1664
+ the previous answer while the new one is on its way, which it did whenever the transcript already held one.
1665
+
1666
+ - 69f7178: feat: the agent chat answers slash commands of its own, and the composer remembers what was sent
1667
+
1668
+ **Five commands join the `/` menu the app's `prompt()` endpoints already appear in**: `/new` (`/clear`) starts a
1669
+ new conversation, `/retry` sends the last message again, `/copy` puts the transcript on the clipboard, `/help`
1670
+ lists the commands, and `/tools` lists what this screen published. An app writes none of them and cannot add one —
1671
+ the extension point for a product's own command is a `prompt()` endpoint, which is guarded and server-side.
1672
+
1673
+ **A built-in wins a name collision with a prompt of the same name**, and a shadowed prompt is dropped from the menu
1674
+ rather than listed twice. That is the mirror image of the tool rule, deliberately: a component's `st.tool` shadows a
1675
+ built-in it means to replace, but no library's prompt may take `/new` away from the user who typed it. `/new` and
1676
+ `/copy` are also dispatched ahead of the is-a-turn-running check, because mid-turn is exactly when they are reached
1677
+ for — so `AgentSession.reset` now ends the turn it is clearing and waits for it to wind down. It used to return
1678
+ silently while one was running, and clearing before the abort lands leaves the dying turn appending onto an empty
1679
+ transcript.
1680
+
1681
+ **A command's output is a `local` message: rendered in the transcript, withheld from the wire.** The transcript
1682
+ _is_ the model's history, so `/help` text appended plainly would come back on the next turn as something the
1683
+ assistant believes it said. `session.note(text)` writes one, `session.report(error)` stays what a host-side failure
1684
+ lands in, and `local` messages are left out of a `/copy` export — they are the chat talking to itself.
1685
+
1686
+ **`/copy` exists because nothing else keeps the transcript.** The relay is stateless and the conversation lives only
1687
+ in that browser, so an export is the one path a wrong answer has to whoever could fix it; it carries the route and
1688
+ the timestamp for that reason. **`session.retry()`** replays only the trailing user message and leaves everything
1689
+ before it in place, so a prompt's own preamble is not sent twice.
1690
+
1691
+ **↑ and ↓ in the composer walk what was sent.** A single-line input has nothing of its own on the vertical arrows,
1692
+ and the half-written draft they were walked away from comes back at the bottom of the walk — the other half of why
1693
+ a turn that fails for a reason unrelated to the ask no longer means retyping it.
1694
+
1695
+ - 5f53462: The composer says a file is being read
1696
+
1697
+ `useChatAttachments` now reports how many files are in the reader, and the composer draws a chip for each. The
1698
+ built-in readers resolve in a tick and nothing was missing before; an `attach` that uploads takes seconds, and
1699
+ since the per-file ceiling moved behind the reader that is now the shape an app is meant to write — so a large
1700
+ photo was seconds of a panel that looked like it had dropped the file.
1701
+
1702
+ - f882bbc: feat!: `st.tool`, `st.expose` and `st.useState` become one chain, and a declaration's type is its mask
1703
+
1704
+ **Breaking.** The three declarations an app writes for the in-page agent had three different shapes and two
1705
+ options that did not carry their weight. They are now one shape — `<entry>(name, …) → .desc(text) → <terminal>`,
1706
+ where the terminal call is the hook — and every option left is one the runtime reads. Migration guide:
1707
+ `local/tool-migration.md`.
1708
+
1709
+ ```ts
1710
+ st.tool("removeTask", { confirm: true })
1711
+ .desc("Remove one task.")
1712
+ .arg("taskId", ID)
1713
+ .opt("force", Boolean)
1714
+ .exec(fn);
1715
+ st.expose("openNote", cnst.LightNote).desc("The note on screen.").value(note);
1716
+ const [tab, setTab] = st
1717
+ .useState("tab", String, { set: true })
1718
+ .desc("Which tab.")
1719
+ .init("all");
1720
+ ```
1721
+
1722
+ **`.desc()` is required, and it is now load-bearing twice.** A model picks a tool by that sentence and by nothing
1723
+ else, so a tool without one was a tool an agent could only guess at. It also replaces `shared: true`: a row
1724
+ component registering fifty `removeTask` is fifty copies of one name _and one description_, which the surface now
1725
+ reads as one declaration, while a second description arriving under a name already taken is two components that
1726
+ collided and still warns. `shared` was an unverified claim whose only effect was suppressing that warning — a flag
1727
+ that said "trust me" where the description already says the same thing, checkably.
1728
+
1729
+ **`effect` becomes `settle`, because only one of its three values ever did anything.** `"query"` skipped the wait
1730
+ for the screen to settle before the call's effect was reported; `"state"` and `"mutation"` were indistinguishable
1731
+ at runtime, were dropped before reaching the model, and coloured a badge. What is left is the fact the session
1732
+ actually needs: `{ settle: false }` is a read that returns what is already there, and everything else is waited
1733
+ out because a write may still be landing when `exec` resolves. `effect` also leaves `PublishedTool`, the relay
1734
+ wire (`AgentWireTool`, `WIRE.md`) and the `/tools` dock badge.
1735
+
1736
+ **`.arg()` is required and `.opt()` is optional**, matching the filter builder's vocabulary, so an optional
1737
+ argument is a different call rather than an options bag — and the `exec` parameter widens to `| null` only for
1738
+ `.opt`.
1739
+
1740
+ **A readable value declares its type, and the type is the mask.** `st.expose(name, Type)` and
1741
+ `st.useState(name, Type)` take a scalar, an enum, a model class, a one-level array of those, or `Any`. The type
1742
+ typechecks what the component hands over — a model resolves to its state object, so a hydrated document and a
1743
+ plain copy of one are equally accepted — and it decides how the value reads: a model strips its own `hidden`,
1744
+ `secret` and `visual` fields by the model that was _named_ rather than by whatever class the value still carries,
1745
+ and a `Date` leaves as an ISO string. That subsumes `mask:` and `serialize:`, both of which are gone; `Any` is the
1746
+ escape hatch and passes the value untouched. A type nothing can read is reported on the console and left
1747
+ unpublished rather than thrown, the same degradation an undescribable `.arg` already had. `.value()` also takes a
1748
+ thunk, read when the agent reads, for a value assembled out of a ref the children fill in after the render.
1749
+ `st.useState`'s `set` is now a boolean, since the write schema comes from the same type.
1750
+
1751
+ - 5f53462: A stopped turn leaves no bubble that is still writing
1752
+
1753
+ `AgentSession` opens an assistant draft before the first token arrives, and Stop caught before that token left the
1754
+ draft in the transcript forever. `Transcript` already drops an empty assistant message, so the wire and the
1755
+ persisted history were both correct and the rendered array was the one place it survived — which is exactly the
1756
+ array a bubble reads to decide it is still being written, so the pulsing dot never stopped. The turn's own settle
1757
+ now drops it, using the predicate `Transcript` already applies. A turn a provider ends without saying anything
1758
+ leaves nothing behind either, for the same reason.
1759
+
1760
+ - 5f53462: An upload control reaches an agent, the way a relation picker already does
1761
+
1762
+ `useFileFieldTool(onChange, { read, label, max, min })` is the counterpart to `useRelationFieldTool` for the other
1763
+ half of _"a relation is picked or uploaded"_: the control hands the files it is holding and gets
1764
+ `load<Field>OptionsOn<Model>` — the name the relation picker already publishes, so one spelling serves a form that
1765
+ draws both — plus the field's own `set<Field>On<Model>`, with `add`/`sub` besides on an array field. Before this an
1766
+ agent could see an attached picture and read the form, and had no way to put one in the other.
1767
+
1768
+ The array pair is not the convenience it is for a list of ids elsewhere. A picker's candidates are the whole
1769
+ resolvable set, so rewriting the array is always expressible; an upload control's are what this conversation
1770
+ brought, and a field that already holds older files cannot be rewritten without ids the guard has never heard of.
1771
+ `max` / `min` are passed rather than derived, since `arrDepth` says a value is a list and not how long a legal one
1772
+ is, and they reach the description as well as the guard — a cap an agent can only learn by tripping the server is
1773
+ the round trip the listing tool exists to spare it. `read` omitted publishes nothing, so a shared control can
1774
+ forward an optional tray without every call site growing a tool that refuses every id.
1775
+
1776
+ - f882bbc: Back a zone's transcript from a mounted `<Agent.History />` instead of a `persist` prop, so the zone can be
1777
+ assembled by a server component.
1778
+
1779
+ A `SessionHistory` is functions, and a function cannot cross the server/client boundary as a prop — so
1780
+ `persist={myStore}` made every ancestor up to whoever builds the session a client component, which for a zone is
1781
+ the whole subtree. `<Agent.History load save clear onCompact />` is a leaf that attaches the store to the
1782
+ enclosing session, in the shape `Agent.Guide` already uses, and renders nothing. `Agent.Zone`'s other props were
1783
+ already serializable, so the zone and the chat inside it can now be server-rendered.
1784
+
1785
+ Restoring follows the rule an async `load` already followed: it lands only while nothing has happened to the
1786
+ conversation yet. Mounting with the zone restores; mounting later saves from there on, and the store is never
1787
+ asked for a transcript that would be discarded. `AgentSession.setHistory` / `setOnCompact` back it, for a session
1788
+ an app built itself.
1789
+
1790
+ `setHistory` and `setOnCompact` return a detach that clears the slot only while their own value is still in it,
1791
+ so a remount's cleanup cannot silently stop the saving of whoever attached after it. The store lives exactly as
1792
+ long as the component; a host that wants it to outlive the view attaches it on the session itself.
1793
+
1794
+ Also documents that `open` without `onOpenChange` draws no close button — a fixed panel with nowhere to close to,
1795
+ and the shape that keeps a controlled chat free of function props.
1796
+
1797
+ - f882bbc: Hide `Agent.Dock` and `Agent.Context` in production.
1798
+
1799
+ Both are a developer inspector — they list every published tool, can run one by hand, and preview the turn snapshot. A layout that mounts them would have shown that to every visitor on `AKAN_PUBLIC_ENV=main`. They still render on local, debug, develop, and testing.
1800
+
1801
+ - 5f53462: feat: the composer draws a pointer as the name it points at
1802
+
1803
+ Picking a row off the `@` menu used to leave `@[이번엔, 진짜입니다](mention:videoCut/6aae…)` sitting in the
1804
+ composer — the token is what carries the reference onto the message, so it had to be in the text, and the text is
1805
+ what a textarea draws. It is now drawn by a Lexical editor instead, where each pointer is one atomic node whose
1806
+ label is what the person reads and whose `getTextContent()` is the token. The draft string the chat reasons about
1807
+ is unchanged, pointers and all: `Reference.parse`, the `@` query, the chips, the wire and the server's own framing
1808
+ all see exactly what they saw before, and the editor is one way of drawing it.
1809
+
1810
+ A pointer deletes in one backspace rather than a character of a label that would then name nothing, and every
1811
+ offset the chat hands over — the caret a `session.refer` inserts at, the line a recall arrow belongs to — stays an
1812
+ offset into that string.
1813
+
1814
+ It costs nothing where it is not used: the editor is its own chunk behind the chat's own, and `Agent.Chat` only
1815
+ draws it where `reference` sources were declared. `mentions={false}` keeps the plain textarea, for an app that
1816
+ overrides the composer or would rather see the tokens it is sending.
1817
+
1818
+ Lexical rather than a contenteditable of our own, because a Korean or Japanese IME composing into a contenteditable
1819
+ that React also re-renders is the bug class the library exists to own.
1820
+
1821
+ - 69f7178: feat: derive an agent surface from the store, annotate the DOM with it, and add a read-only insight query
1822
+
1823
+ The server half of agent support is the signal registry; this is the client half plus the layer-bypassing read. All
1824
+ of it is derived — an app writes no declaration for any of it.
1825
+
1826
+ **`SerializedStore` and `StoreCatalogue`** describe one built store the way `SerializedSignal` describes a signal
1827
+ registry: every key on `st.use` and every action on `st.do`, with argument schemas. Flat rather than grouped by
1828
+ model, because that is what the store is — one namespace, where a key means the same thing to every reader.
1829
+
1830
+ The exposure default is the opposite of the MCP catalogue's, and deliberately: a key on `st.do` is the same call the
1831
+ user's own click makes, under their own credential, with them watching, so an agent driving it cannot reach past what
1832
+ the UI already permits. Every key is published unless something about it cannot be described, and each of those is
1833
+ recorded as a refusal with the reason — a `hidden`/`secret` field setter (the masking boundary facing the other way),
1834
+ a `Map` or relation field, a `FileList` upload, `selectModel` (it stores the list item it was handed, so an id would
1835
+ leave a stub), and any action that declares arguments no endpoint or field describes.
1836
+
1837
+ Nothing re-derives a name rule. An action's arguments come from the endpoint it is named after — which is not luck
1838
+ but the house rule that makes `st.do.X` read the same as `fetch.X` — or from the field metadata the setter was
1839
+ generated from, or from the role the store recorded while it built the slice. Two things had to be added to make that
1840
+ possible: arity is captured in `#mergeActions` (the `st.do` wrapper takes rest arguments, so `Function.length` is
1841
+ zero for everything by the time anyone can ask), and `ACTION_OWNER_META` records which module declared each action,
1842
+ which is what names the dictionary node its words are read from.
1843
+
1844
+ **`AgentBridge`** turns that into tools with JSON schemas, checks each argument against what it declared, dispatches
1845
+ through `st.do`, masks reads, and keeps a transcript. It holds no model, provider, or key — an app wires whichever
1846
+ agent it uses to `tools` / `call` / `read`. `Agent.Dock` in `akanjs/ui` renders the catalogue, the refusals and the
1847
+ transcript, and can run a tool by hand; it is how you see what a page actually publishes.
1848
+
1849
+ Reads are masked because they have to be: `<model>Form` holds what the user just typed, credentials included, and an
1850
+ in-page agent ships what it reads to a remote model. The mask is by the declared model rather than by the value's
1851
+ class, since `immerify` copies a form into a plain object and the class is gone by then. `Msg.mask` moved to
1852
+ `constant/mask.ts` so both audiences use one implementation.
1853
+
1854
+ **`data-akan-action` / `data-akan-state`** are emitted by the framework's own interactive primitives, with no app
1855
+ code at all. `onChange={st.do.setNameOnUser}` — the house form for every model field — already carries everything an
1856
+ annotation needs; it just had no way to be read off a function, and now it does. `Input` (every variant plus
1857
+ `Checkbox`), `Select`, `Switch`, `Button`, and the `Field.*` fields all carry it. An inline arrow gets nothing,
1858
+ because a closure the caller wrote says nothing about what it does. Accessibility trees, E2E selectors, and external
1859
+ browser agents get the same names for free.
1860
+
1861
+ **`InsightQuery`** is one read-only SQL statement, for a question the domain endpoints cannot express. Read-only is
1862
+ enforced four ways — the statement is wrapped as a derived table, where nothing but a query is legal in either
1863
+ dialect; a pre-execution check runs on the statement with comments and string literals removed; forbidden keywords
1864
+ are rejected rather than trusting that Postgres refuses a data-modifying CTE inside a subquery; and the `_doc` column
1865
+ never crosses the boundary, since that is where every maskable field lives and an arbitrary SELECT names no model to
1866
+ mask by. Rows are capped at 1000, which no caller can raise. The timeout bounds the wait, not the query —
1867
+ `bun:sqlite` is synchronous and holds the loop, so the ceiling is what limits that case.
1868
+
1869
+ What the insight query costs is worth stating: it answers "how many, since when, grouped how" over base columns and
1870
+ the search mirror, and it cannot read a domain field. Field-level reads go through the domain tools, which mask.
1871
+
1872
+ `libs/shared` exposes it as `runAdminSql` on the admin signal, guarded by `SuperAdmin` and returning the new
1873
+ `insightRows` scalar. It is a `mutation` despite not being able to write, because a `query` is a GET that never sends
1874
+ a body — the statement would have to travel in the URL, where it lands in every access log and hits the length limit
1875
+ — and because a query's response is memoized per URL, which is wrong for an ad-hoc statement. It is not MCP-exposed:
1876
+ the catalogue is built once at boot rather than per caller, so publishing it would tell every client that can reach
1877
+ `/mcp` that an arbitrary-SQL tool exists.
1878
+
1879
+ - 5f53462: `readScreen` names every image, and can carry its address
1880
+
1881
+ An `<img>` with no `alt` contributed nothing at all, so a card rendering a picture read exactly like a card
1882
+ rendering nothing — and "this page shows no image" is the one answer the screen could not support. Every image now
1883
+ stands as `[image: <alt>]` or `[image]`, and `readScreen({ images: true })` appends `(<src>)` for handing one to a
1884
+ tool that takes a picture. It is off by default because a gallery is one long URL per thumbnail, and a `data:` URL
1885
+ is never printed: that is the bytes themselves rather than somewhere to fetch them.
1886
+
1887
+ - 69f7178: fix: refuse `runAgentTurn` until the app names an `AgentRelayAccess` guard
1888
+
1889
+ The relay used to allow every caller and warn at boot until an app decided. With no guard it now answers like
1890
+ `None` — `Access denied by guard: AgentRelayAccess` — and boot is silent. Name one with
1891
+ `option.setAgentAccess(...)` (or `AgentRelayAccess.use`) before the chat can spend the LLM key.
1892
+
1893
+ - f882bbc: Open the in-page agent's session to the host: a custom transcript store, a narrowed built-in tool set, and the
1894
+ types a replacement transport needs.
1895
+
1896
+ - `persist` now takes a `SessionHistory` (`{ load, save, clear }`) as well as the web-storage option, and all
1897
+ three methods may answer asynchronously — a transcript can live on a server. A `load` still in flight when the
1898
+ user sends the first message is dropped rather than merged, and saves are chained so a slow store cannot land
1899
+ an older transcript last. `session.isRestoring` reports the in-flight load — a host that opens with a prompt of
1900
+ its own waits for it, since sending on mount is a race the restore loses.
1901
+ - `builtins` on `Agent.Chat` and `Agent.Zone` picks which of the runtime's own tools a session gets (`false` for
1902
+ none, an array for exactly those). A withheld name answers the same "unknown tool" an unregistered one does.
1903
+ This is the only way to drop `navigate` inside a zone, where a same-named hook tool is registered under its
1904
+ scope prefix and never shadows the built-in.
1905
+ - `Agent.Zone` takes a `session` the app built (and then does not abort it on unmount) and reports it through
1906
+ `onSession`. `Agent.Chat` takes `chrome={false}` to drop the header bar and `defaultDraft` to open with text
1907
+ in the composer; a panel controlled without an `onOpenChange` draws no close button instead of an inert one.
1908
+ - `onCompact(replaced, summary)` reports a compaction's cut, for a host keeping its own summary watermark.
1909
+ - `akanjs/ui` now exports `httpRunner`, `fetchRunner`, `AgentSession`, `AgentProvider` and the `AgentRunner` /
1910
+ `RunnerRequest` / `RunnerEvent` / `ChatMessage` / `SessionHistory` / `PublishedTool` / `ContextBlock` types.
1911
+
1912
+ - 5f53462: Two providers that read a picture ship, and `accepts` is answered per model
1913
+
1914
+ Everything the framework carries for an attachment ended at a text-only provider: one chat-completions
1915
+ `LlmAdaptor` shipped and declared no `accepts`, so an image reached the model as a note saying it could not
1916
+ be read, and any app wanting vision wrote the provider mapping itself — role alternation, tool-call placement,
1917
+ streaming reassembly, SSE framing, none of it app-specific work.
1918
+
1919
+ `OpenaiLlm` speaks the chat-completions dialect against `https://api.openai.com/v1` and declares `{ image: true }`,
1920
+ sending an image as a content part from either carrier. `AnthropicLlm` is the Messages API and declares
1921
+ `{ image: true, document: true }`. That dialect now lives in `OpenaiDialect`, so a
1922
+ protocol fix lands once; Anthropic shares nothing but the wire it maps from, its system prompt being a field
1923
+ rather than a message, its tool calls and results content blocks, its results a _user_ turn, its roles strictly
1924
+ alternating, and its `max_tokens` required. Both new adaptors require `model`: a default would age into a 404 and
1925
+ would decide the vision claim on the app's behalf.
1926
+
1927
+ `option.setLlm({ accepts })` overrides what the configured model reads. An adaptor answers for an API and one API
1928
+ serves models that differ, so the answer rides beside the `model` it is a fact about rather than in a capability
1929
+ table the framework keeps — a table is a claim about models that ship after it, and a provider handed bytes it
1930
+ cannot decode either refuses the turn or accepts it having seen nothing.
1931
+
1932
+ `option.setLlm({ maxTokens })` is the answer ceiling for an API that requires one. A fixed default is a hazard on
1933
+ a model that reasons before it writes: the budget goes on thinking, the turn comes back empty with a length stop,
1934
+ and that reads as the model refusing — so an empty answer is also named in the log with the current number.
1935
+ Sampling knobs are deliberately absent from `LlmOption`, because `temperature` is a 400 rather than an ignored
1936
+ field on some models and the role would have to guess its legality per model.
1937
+
1938
+ A mangled SSE frame now costs that frame instead of the whole answer, in both stream readers: throwing lost the
1939
+ text already streamed over one unreadable line of a protocol the caller cannot fix.
1940
+
1941
+ Both match an exact image-type set (`jpeg`, `png`, `gif`, `webp`) rather than an `image/*` prefix, and name the
1942
+ rest in the text. `accepts.image` is one boolean, so `AgentService.readable` passes every `image/*` through and the
1943
+ composer's built-in reader base64s every `image/*` — an `AttachReader` answering `null` means "not mine" and falls
1944
+ through to it — so an app cannot gate one either. An unsupported type is not one unread attachment: it is a block
1945
+ the API refuses, so the whole turn dies on a vendor 400, and `image/heic` is the iPhone camera default.
1946
+
1947
+ `OpenaiLlm` is the default. Swap with `option.applyAdaptor(LlmAdaptorRole, AnthropicLlm)`.
1948
+
1949
+ - 5f53462: An attached image shows up, and survives a reload with its handle
1950
+
1951
+ The attachment chip only drew a thumbnail for an inlined `data` attachment, so an image attached by `url` — the
1952
+ shape the per-file ceiling exists to encourage, and the only shape a deployed app produces — rendered as a bare
1953
+ name in the composer and in every bubble. It draws either carrier now.
1954
+
1955
+ `sessionHistory` dropped `ref` on the way to storage. The reason content is stripped is that web storage is a few
1956
+ megabytes and a failed save is silent; an id is not content, and it is the one thing a restored conversation has
1957
+ left to find the file again — without it a host is back to guessing from name and size, which is the guess `ref`
1958
+ was added to retire.
1959
+
1960
+ `Chips` is exported as `AgentAttachments`, so a replaced `AgentBubble` draws the attachment row with the framework's
1961
+ rule rather than a copy of it. And a compacted attachment now says its content is gone instead of only naming
1962
+ itself, on the same principle as every other note here: a model told only `[attached photo.png]` answers about the
1963
+ picture from its filename.
1964
+
1965
+ - 69f7178: feat: akanjs/ui drives itself for an in-page agent
1966
+
1967
+ The framework's own components now publish the controls they draw, so an app gets a working agent surface for
1968
+ list management, tabs, paging, forms, dialogs, and the app shell without writing a line. Names are the store
1969
+ action's wherever one exists (`setSortOfTaskInOrg`, `removeTask`), so the tool an agent calls and the action the
1970
+ button dispatches read the same.
1971
+
1972
+ - **`Data.ListContainer`**, and so every `Model.AdminPanel`: view mode, sort, page size, refresh, create, the two
1973
+ exports, and the row and modal verbs — `edit`/`view`/`remove` by id, plus `submit`, `cancelEditOf`,
1974
+ `closeViewOf`. It also opens a `<slice>.items` resource, which is where the id for a row verb comes from.
1975
+ - **`Tab`**, **`Dialog`**, **`ScreenNavigator`** take a `namespace` prop and publish under it. Without one they
1976
+ publish nothing: two tabs on one screen would otherwise answer to a single name, and the first to mount would
1977
+ lose. `Tab`'s menu registry became a `Map` of menu to disabled, which also stopped the disabled-tab fallback
1978
+ from landing on another disabled tab.
1979
+ - **Paging** is one shared `usePageTool`, spoken by all three components that draw a pager.
1980
+ - **`Layout.Sider`**, **`System.SelectLanguage`**, **`Link.Back`** publish the shell controls.
1981
+
1982
+ **Forms fill themselves, from two sides.** An app writes no `st.tool` for a form.
1983
+
1984
+ A form control handed its setter **by reference** — `onChange={st.do.setTitleOnTask}` — publishes
1985
+ `setTitleOnTask` while it is on screen. That is the same reference that already earned `data-akan-action`, so the
1986
+ agent's tool and the person's control are one function, and an inline arrow still publishes nothing. Scoping
1987
+ publication to the control rather than to the model is what keeps an agent out of a field the template draws
1988
+ nothing for.
1989
+
1990
+ `st.use.taskForm()` adds one more: `fillTaskForm(patch)`. It takes several fields in a single call, and it is the
1991
+ only way to reach a list, a map, or an embedded object — their rows are written through
1992
+ `writeOnTask("payments.3.name", value)`, an inline call that can carry no annotation. It is a patch, so a field
1993
+ left out keeps what the person typed. Its schema is every writable field, because a declaration is mount-static;
1994
+ the **guard** is where the screen gets its say, refusing a plain field whose control is not on screen and naming
1995
+ the ones that are. A composite is let through, because nothing can see whether its rows rendered — the one place
1996
+ left where an agent reaches a field the screen may not draw, and server guards still apply.
1997
+
1998
+ Neither side touches a relation (picked or uploaded, never typed), a base document field, or a `hidden`/`secret`
1999
+ one **at any depth**: a read of those is masked, so publishing a writer would open the door its reader is barred
2000
+ from. A rejected third shape is worth naming — a single `writeOnTask(path, value)` tool — because its `value`
2001
+ could only be `Any`, which this framework's own MCP layer refuses for telling a model nothing, and a mistyped
2002
+ `path` writes a new key into the form that ships on the next submit.
2003
+
2004
+ Nothing publishes a lever the screen does not have. A model with fewer than two sort keys draws no sort control
2005
+ and has no `setSortOf<Model>`; a panel with no template draws no create button and has no `new<Model>`; a list
2006
+ that fits on one page has no pager and no `setPageOf<Model>`. Callables go to the controls by reference, so
2007
+ `data-akan-action` lands on them and `readScreen` names each control with the word its tool has.
2008
+
2009
+ Three gaps in the surface had to close first, all of them consequences of the surface being declaration-only.
2010
+
2011
+ **A conditional surface had no legal shape.** `.exec()` is a hook, so a component can never skip the declaration —
2012
+ which left no way to publish a tool only when the screen renders its control. A falsy name now declares the tool
2013
+ and publishes nothing: the callable still drives the click a person makes, and the agent never learns it exists.
2014
+ `st.useState` and `st.expose` take a falsy name the same way. An unpublished callable carries no
2015
+ `data-akan-action`, because that attribute names a tool an agent can reach and this one cannot. This is what lets
2016
+ a list toolbar publish `setSortOfTask` only when it actually draws the sort control, instead of paying for the
2017
+ tool in every turn's prompt on every screen.
2018
+
2019
+ **A value set only the render knows had no way in.** An `enumOf` class was already a complete argument type —
2020
+ `.arg("mode", TaskStatus)` publishes the values, refuses anything off them, and narrows the `.exec` parameter to
2021
+ the union — but a component cannot build one, because `enumOf` registers globally. A slice's sort keys or the
2022
+ options a prop carried could only be described in prose and hoped for. `.arg(name, type, { oneOf })` takes the
2023
+ list the render has and publishes and enforces it the same way. Neither reaches a set that fills in _after_ the
2024
+ first render, since a declaration is mount-static; that belongs in the tool's `guard`, which is re-read per call
2025
+ and can name the current values in its refusal — which is what `Tab` and the pagers do.
2026
+
2027
+ **A list inside an `Agent.Zone` was invisible to that zone's own agent.** `useScreenScope` opened its scope at the
2028
+ root rather than under the scope it is mounted in, so `Load.Units` inside a zone registered `<slice>.items` at the
2029
+ top. A zone view only sees keys in its own subtree, so the root agent could read the list and the zone agent
2030
+ looking straight at it could not.
2031
+
2032
+ - 69f7178: fix: an undescribable `st.tool` argument withdraws the tool instead of throwing during render
2033
+
2034
+ `.arg(name, type)` validated the type where it was written and threw for anything that was not a scalar or an
2035
+ enum. That check runs during render, so a model class passed to one argument of one component tool aborted the
2036
+ server render of the whole route — React fell back to client rendering with `st.tool takes scalar and enum
2037
+ arguments only.`, a message naming neither the tool, nor the argument, nor the type.
2038
+
2039
+ It now withdraws the tool the way a falsy name does — nothing is published, the callable still drives the click a
2040
+ person makes, and the page renders — and reports it as
2041
+ `st.tool("editProject") is not published: its "info" argument is the type PortfolioInfo, and st.tool takes scalar
2042
+ and enum arguments only.` `st.useState`'s `set` degrades to read-only on the same terms. This matches what the
2043
+ surface already did everywhere else: `FormFields` drops a field it cannot describe, and MCP refuses an endpoint
2044
+ and names it in the boot log. An agent-tooling concern should not be able to cost a route its server rendering.
2045
+
2046
+ Note that form fields were never able to trigger this: `useFormTools` and `useFieldTool` build their schemas from
2047
+ an effect, which does not run during SSR. A regression test now server-renders a form over a model carrying an
2048
+ embedded-scalar array field to keep it that way.
2049
+
2050
+ - 5f53462: `st.tool` describes one array level of a scalar or an enum
2051
+
2052
+ `.arg("bodies", [String])` and `.opt("modes", [TaskStatus])` publish `{ type: "array", items }` and check every
2053
+ element, with `oneOf` narrowing the elements rather than the list. It is the schema `fill<Model>Form` already
2054
+ built for an array field, so the same shape reaching an agent through a form and not through a component tool was
2055
+ an oversight — a tool that could not say "a list of these" taught the format in prose, and the app then owned a
2056
+ parser for it.
2057
+
2058
+ - 69f7178: fix!: publish only what a component declared to the in-page agent
2059
+
2060
+ This supersedes the store-derived in-page surface described in the earlier entries of this release: the store
2061
+ catalogue keeps its state half, and its action half is gone.
2062
+
2063
+ An in-page agent stands in for the person looking at the screen, so what it may do is what that screen offers them
2064
+ and nothing else. Deriving its tools from the store missed that by one level: liveness was per _store_, so a single
2065
+ `st.use.me()` in a gating component published every method the admin module had ever declared — `setAdminPassword`
2066
+ among them — and `readState` reached every key of that store rather than the keys the screen reads. Levers the
2067
+ screen does not have are not reach, they are noise the model pays for on every turn.
2068
+
2069
+ **`st.tool` is the only way an action reaches an agent.** The store catalogue's whole action half is gone: endpoint
2070
+ argument borrowing, generated form setters, slice actions, no-argument custom actions, the arity check against the
2071
+ same-named endpoint, the JSON-schema build, the argument checking, `AgentBridge.call`, and its transcript. A store
2072
+ method reaches an agent by being wired into one — `.exec((id) => st.do.removeX(id))` — which is also the shape that
2073
+ hands the same callable to `onClick`, so the person and the agent press one handler. `remove*` still confirms by
2074
+ default; the gate moved to `StToolBuilder` with everything else.
2075
+
2076
+ **Reading is per key, not per store.** `AgentBridge.read` gates on the key being subscribed in that view, so a
2077
+ component reading `userList` says the screen shows a list and says nothing about the `userForm` beside it in the
2078
+ same store. The catalogue keeps its state half, because a read has to be masked and only the store declares which
2079
+ model masks it.
2080
+
2081
+ **`static agent` is gone.** It existed to trim an exposure nothing derives any more, so a store class now says
2082
+ nothing about agents at all — which is the same rule as the tools. `st.use.x({ agent: false })` is how the
2083
+ component that subscribes a value keeps it off the surface, and it is the component's call because the component
2084
+ is what put the value on screen. Delete any `static agent` declaration; there is no replacement to write.
2085
+
2086
+ Base-store plumbing follows the same rule as any other key: `st.use.path({ agent: false })` and
2087
+ `st.use.tryJwt({ agent: false })` keep routing and the caller's credential off the surface at the call site. The
2088
+ route block still carries pathname, params, and searchParams. A screen that wants an agent to read a base key
2089
+ opts it in — ThemeToggle subscribes `theme` without the opt-out.
2090
+
2091
+ **`Agent.Dock` reads the surface** rather than the store, so its tool list is what the page declared, and
2092
+ `AgenticSurface` keeps the last 200 calls so the transcript now covers every tool instead of the store's own.
2093
+
2094
+ - 5f53462: A turn the provider cut off says so, instead of passing as a finished one
2095
+
2096
+ `LlmTurnAnswer.stop` gains `"length"` — `finish_reason: "length"`, `stop_reason: "max_tokens"` — and it rides the
2097
+ wire and the `AgentStop` enum to the browser. It is not one provider's quirk: every adaptor here reports it,
2098
+ including the default, and only the shape of the union was hiding that.
2099
+
2100
+ Without it a truncated answer arrived as `stop: "end"` and the user read half a sentence as the whole reply. Worse
2101
+ on an agent surface: a turn cut off mid tool call carries no complete call, so it ended the loop looking exactly
2102
+ like a model that had decided it was done — a hang the transcript explained as a choice. The session now records
2103
+ the turn as incomplete on the assistant message the user is reading, and closes any call the turn did make rather
2104
+ than running it, since a batch the model was interrupted inside is half an intention.
2105
+
2106
+ The ceiling wins over the calls that did arrive, in both adaptors and on both the streamed and whole paths.
2107
+
2108
+ - f882bbc: fix: `st.expose` / `st.useState` typed `String` or `Boolean` resolve to the scalar, not to a model state object
2109
+
2110
+ `AgentValueOf` matched a declaration's `FIELD_META` before its `CLIENT_VALUE`, and `via.ts` augments the global
2111
+ `String`, `Boolean`, `Date` and `Map` constructors with `DatabaseConstantStatics` — which carries `FIELD_META`. So
2112
+ those constructors read as model classes: `st.useState("tab", String, { set: true }).desc("…").init("all")` handed
2113
+ back `GetStateObject<String>` instead of `string`, and the same for `Boolean`. `Date` escaped only because it is
2114
+ special-cased above, and `Int` / `Float` / `ID` / `Any` are declared classes rather than augmented globals.
2115
+
2116
+ Only the value `st.useState` returns was wrong. `.value()` on `st.expose` kept accepting the literal, because a
2117
+ string is assignable to `GetStateObject<String>` — which is exactly why nothing caught it: the runtime, the
2118
+ schemas, and every `st.expose` call site were correct throughout.
2119
+
2120
+ A scalar is now matched by `refName`, above the model branch. A model carries no `refName`, so nothing about the
2121
+ model path changes — a model still resolves to its state object and still takes a hydrated document or a plain
2122
+ copy of one. `AgentValueOf` is pinned branch by branch in `AgentValue.test.ts`, and `StStateBuilder.test.tsx`
2123
+ covers a `String` and a `Boolean` state end to end.
2124
+
2125
+ Anyone on `3.0.0-alpha.53` who worked around this with `as unknown as string` / `as unknown as boolean` on an
2126
+ `st.useState` result can delete those casts.
2127
+
2128
+ - 69f7178: feat: the in-page agent waits for the screen, aims its reads, points at things, and says what it is doing
2129
+
2130
+ **A tool that changes the screen now waits for the screen before it answers.** `router.push` returns while the RSC
2131
+ payload for the new route is still in flight, so `navigate` used to report success onto a page that had not
2132
+ rendered yet: the `readScreen` in the same turn read the page the user had just left, and the new route's tools were
2133
+ not registered. `ScreenSettle.wait()` waits for DOM quiescence — bounded, and quiescence rather than a framework
2134
+ signal because the client router hands its promise to nobody and a change may land in the store, in a refetch, or
2135
+ in a streamed Suspense boundary. `navigate` awaits it, and `AgentSession` awaits it after every non-`query` tool
2136
+ before taking the change report, so an optimistic action that commits a tick later is reported as what it did
2137
+ rather than as the moment before it. Tools and state from a fresh route are still only listed from the next turn —
2138
+ the catalogue is snapshotted when a turn starts — and the navigate result says so.
2139
+
2140
+ **`goBack` joins `navigate` as a global built-in.** It was briefly declared by `Link.Back`, which made it exist
2141
+ only where a back link happened to be rendered — but history is not a control a page owns: every route has a
2142
+ previous page, the browser's own gesture is always there, and a page that draws no back link is not a page you may
2143
+ not leave. It refuses at call time when nothing is behind the current page instead of walking out of the app.
2144
+
2145
+ **`readScreen` takes a `section` and there is a new `highlight(target)`.** Both resolve a name the agent has
2146
+ already seen rather than a selector it invented: a `data-akan-action` / `data-akan-state` annotation (the one
2147
+ `readScreen` prints beside a control), an `Agent.Zone` or `useScreenScope` container, an element id, or a heading
2148
+ by its own text — matched on letters and digits, so the slug an agent writes for a heading it read resolves. That
2149
+ tolerance stops at headings: a heading is a landmark and scrolling to the wrong one costs nothing, while two
2150
+ buttons reading "Save" are not the same control. Nothing hidden resolves at all, because a ring nobody can see
2151
+ reads as a broken tool rather than as a miss. A name that resolves to nothing is the caller's mistake, with a
2152
+ refusal that lists the sections actually on screen.
2153
+
2154
+ **A screen is only aimable if its names are printed**, which is what the first version got wrong: a page of twenty
2155
+ `Scroll.Slide` sections answered "nothing on this screen carries a name", because `readScreen` printed the heading
2156
+ text without its anchor and the truncation note stopped at a character count. Headings now carry `(#anchor)` when
2157
+ they open an id'd or scoped container, and a truncated read ends with the headings below the cut — otherwise
2158
+ everything past the 8000-character limit is unreachable, since nothing names it. `highlight` flashes **after the
2159
+ scroll lands**: a smooth scroll across a long page outlasts a flash begun at the top, which is the same bug wearing
2160
+ a different hat. `useScreenScope` now hands
2161
+ back its scope path and `Load.Units` / `Load.View` / `Data.ListContainer` put it on the container they render, so
2162
+ every list and detail view on an akan screen is addressable with no app code. `highlight` scrolls its target into
2163
+ view and flashes it on the app's own primary token: it is the one built-in that exists for the _user's_ benefit,
2164
+ because showing where a control is beats writing directions to it.
2165
+
2166
+ **A slow tool can say what it is doing.** `AgentProgress.report(message, { done, total })` is the browser twin of
2167
+ `McpProgress.report` — reached through a module slot rather than a parameter, so work several frames down (a store
2168
+ action, an upload loop, an adapter) reports without every signature growing a channel argument, and a no-op when
2169
+ nobody is rendering it. The chat shows the report on that call's row until the row resolves. A session runs tool
2170
+ calls one at a time, which is why the browser needs no `AsyncLocalStorage` to do this.
2171
+
2172
+ **The turn cap is a question instead of a dead end.** At `maxTurns` the session asks whether to keep going through
2173
+ the same card `askUser` uses, and the answer rides as the user's own turn — so a steer typed instead of the
2174
+ keep-going choice reaches the model as guidance rather than being swallowed. A host that renders no
2175
+ `pendingQuestion` passes no `continueAsk` and keeps the old failure, because asking with nobody listening would
2176
+ hang.
2177
+
2178
+ - 69f7178: feat: the in-page agent waits out long work instead of polling it, and Stop reaches a tool that is still running
2179
+
2180
+ **An agent that started something slow had one way to learn it finished: ask again.** Every look is a full model
2181
+ round trip, so a two-minute video generation burned the default eight-turn budget in about twenty seconds and read
2182
+ to the user as check → wait → check → wait, forever. Two things change.
2183
+
2184
+ **A tool that awaits its own work now holds the turn, with no model round trip in between.** This always worked —
2185
+ the session awaits `#execute` and `AgenticSurface.call` awaits `entry.run` — but nothing said so and nothing made
2186
+ it safe, so apps wrote fire-and-forget tools and left the agent to poll. The shape is one `st.tool` whose `.exec`
2187
+ awaits the store action that finishes the job; the change report that follows carries whatever landed while it
2188
+ waited, so the model needs no second call to read the result.
2189
+
2190
+ **Stop now ends a turn parked inside a tool.** `AgentSession` passed its abort signal to the approval and question
2191
+ cards and nowhere else, so a call that took two minutes held the loop for two minutes after the user pressed Stop,
2192
+ with the chat still showing a turn in flight. Latent until now, and a certainty the moment a tool is allowed to
2193
+ wait. The session races every call against the signal, and hands the signal to the tool through `AgentAbort` — the
2194
+ module slot `AgentProgress` already is, for the same reason: work several frames down reads it without every
2195
+ signature between here and there growing an argument, and a session runs tool calls one at a time. Honouring it is
2196
+ optional, since the race lands whatever the tool does; what it buys is the tool's own cleanup, such as a timer that
2197
+ would otherwise tick out its whole timeout with nobody left to answer. A tool that ignores it is left running
2198
+ rather than cancelled — the work is usually a job a server is already doing, and throwing away a result that is
2199
+ about to land helps nobody.
2200
+
2201
+ **`AgentAbort` and `AgentProgress` are re-exported from `akanjs/store`.** `AgentProgress` had no export path an app
2202
+ could legally use, `use-agentic` being a third-party import from `apps/**` and `libs/**`, so the documented advice
2203
+ to report progress from a slow tool was not followable. A tool that can neither report progress nor honour Stop is
2204
+ exactly the tool this release exists to make writable.
2205
+
2206
+ - 5f53462: An endpoint's declared `timeout` bounds the call, on both ends, instead of being read by nobody
2207
+
2208
+ `mutation(cnst.X, { timeout: 5 * 60 * 1000 })` could not work. The value had exactly one reader — the `Timeout`
2209
+ middleware, which no app registers because it is opt-in — it was never serialized to the client, `FetchPolicy.timeout`
2210
+ was accepted by the public type and dropped by `#makeHttpFn`, and `HttpClient` was constructed without one, so every
2211
+ non-upload request was abandoned at the client's hard-coded 30 seconds and restored as `base.error.gatewayTimeout`.
2212
+ Anything whose real work outlives half a minute — hardware provisioning, a firmware flow, an SSH round trip, an
2213
+ external orchestration — was unreachable from a browser, and the declaration on the endpoint read as if it were
2214
+ configured.
2215
+
2216
+ The declared value now travels. `FetchSerializer` puts it on the serialized endpoint and `fetch.<endpoint>()` makes
2217
+ it that request's budget, a caller overrides it per call with `{ timeout }` (`false` waits as long as the runtime
2218
+ will), and an app moves its own default with `fetch.instance.setTimeout(ms)` — precedence caller, endpoint,
2219
+ client default, with an upload still bounded by nothing. Server side the middleware is registered by default and has
2220
+ **no default of its own**: it stands aside for every endpoint that declared nothing, which is what makes registering
2221
+ it safe, and what made the 5-second fallback it used to apply the reason it could not be. It now rejects with a 504
2222
+ carrying `base.error.gatewayTimeout` rather than a bare `Error` that `SignalFailure` generalized to a 500, so both
2223
+ sides of a deadline produce the sentence the dictionary already had, and it clears its timer — the loser of the race
2224
+ was a pending timer per call, holding the event loop for the whole budget on a call that answered immediately.
2225
+
2226
+ What is still true, and is now written down: losing the race does not cancel the work. `Promise.race` cannot reach
2227
+ into `next()`, so a handler that timed out runs to completion with nobody holding its result. A `timeout` bounds
2228
+ what a caller waits for; an operation that must not half-happen needs its own idempotency.
2229
+
2230
+ `cache` had the same shape and got the same treatment. The `Cache` middleware was opt-in, ignored the declared
2231
+ `cache` value for a hard-coded 60 seconds, and — had anyone registered it — would have cached every endpoint in
2232
+ the app including mutations, under a key that names only the endpoint and its arguments, on a hit that skips
2233
+ `next()` and therefore skips the guards. It is now registered by default, reads the declared value as its TTL,
2234
+ runs `context.checkGuards()` before handing an entry over, and takes an entry only for a `query` that declares no
2235
+ internal argument — internal arguments are how a call learns who is asking, so an endpoint that has them answers
2236
+ per caller and a shared entry would be one caller's answer handed to the next. A `mutation` or a per-caller query
2237
+ that declares `cache` is named once in the log instead of being silently ignored, and a cache backend that is down
2238
+ is warned about rather than failing the call.
2239
+
2240
+ `Retry` is removed. Three attempts with a fixed backoff, on an endpoint whose failure it cannot classify, replays
2241
+ whatever the first attempt already did — and it was unreferenced.
2242
+
2243
+ - ccf2ae2: An app's or lib's typecheck no longer fails with TS7016 on `react-server-dom-webpack/client.node` once an install
2244
+ links the package into `akanjs`: the SSR renderer references the ambient declarations it needs itself.
2245
+ - 5f53462: fix: scope the auth cookie and storage key to the app so two apps on one host stop clobbering each other
2246
+
2247
+ Cookies carry no port (RFC 6265), so every app served from `localhost` shared one jar. Under the global `jwt`
2248
+ key each signin overwrote the neighbour's session, and the JWT's own `appName` check only turned the surviving
2249
+ cookie into a credential that fails every guard — the other app fell back to anonymous, guard failures, and a
2250
+ login wall.
2251
+
2252
+ The key is now `jwt:<appName>`, written by `setAuth` to both the cookie and client storage and read by
2253
+ `getAccount`, `initAuth`, the SSR `fetchClient` credential, the websocket credential snapshot, and the CSR boot.
2254
+ `authTokenKey()` and `getAuthToken()` are exported from `akanjs/client` (and `authTokenKey`, `readAuthToken`,
2255
+ `isOwnAuthToken`, `isAuthTokenKey`, `cookieHeaderHasAuthToken`, `legacyAuthTokenKey` from `akanjs/common`) so an
2256
+ app's own middleware reads the same key instead of spelling it out. Read the cookie through `getAuthToken()`
2257
+ rather than `getCookie("jwt")`.
2258
+
2259
+ `initAuth` now checks the token's `appName`/`environment` before handing it to `fetch.setJwt`, which it never
2260
+ did: a `?jwt=` for another app used to ride along on every request. A mismatch is ignored, and clears the cookie
2261
+ when it came from this app's own key.
2262
+
2263
+ **Breaking, with a migration path.** Reads still fall back to a pre-scoping `jwt` cookie, but only when the
2264
+ token's payload names this app — so an existing session survives the upgrade and a neighbour's leftover is never
2265
+ adopted. Writes only ever use the scoped key and delete the legacy one. The fallback is temporary; once
2266
+ deployments have rotated past it, the legacy read goes away and a stale session needs one re-login.
2267
+
2268
+ - 69f7178: fix: a `Boolean` argument parses the text the wire actually carries
2269
+
2270
+ A query string carries text, so `.search("archived", Boolean)` handed `Boolean._parse("true")` a string and threw
2271
+ `Invalid Boolean value: true` before the endpoint ran. The framework's own client sits on the other end of that —
2272
+ `HttpClient.makeUrl` writes `String(value)` — so no caller could reach such an endpoint, hand-written URL or not.
2273
+ `Int` and `Float` never had the problem because their `parseValue` is `Number(input)`, which absorbs a string;
2274
+ `Boolean` was the one primitive that compared against `true` / `1` and nothing else.
2275
+
2276
+ The normalization lives on the primitive rather than in the search-arg branch, because text is what three separate
2277
+ paths deliver: `URLSearchParams.get`, a path `.param()`, and every field of a `fileUpload` mutation's `FormData`.
2278
+ One place to normalize is also one place to keep the accepted set from drifting apart.
2279
+
2280
+ `true` / `false` / `1` / `0` are accepted, trimmed and case-insensitive — `str(True)` from a Python client is
2281
+ `"True"`, and a 500 is a poor answer to it. `""` and `"yes"` are still refused. An empty query value is a question
2282
+ about nullable search args in general rather than about booleans: `Int` reads `""` as `0` today and `String` reads
2283
+ it as a legitimate value, so answering it for one scalar alone makes the set less predictable, not more.
2284
+
2285
+ `serializeValue` takes the same spellings, since the client serializes an argument before `makeUrl` stringifies it
2286
+ — a `<select>` value handed to a `Boolean` argument now normalizes on the way out instead of throwing.
2287
+
2288
+ - 2d8de1c: feat: helpers the code cleanup made public
2289
+
2290
+ Consolidating duplicated helpers left a few new public names. Each is an addition; nothing was renamed or removed.
2291
+
2292
+ - `akanjs/common`: `isRecord(value)` (a type guard for an object that is neither `null` nor an array),
2293
+ `round(value, digits = 3)` and `toError(reason)` (an `Error` as is, anything else wrapped in one).
2294
+ - `akanjs/document`: the `SaveEventListener<Doc>` type, the listener `listenPre` / `listenPost` take.
2295
+ - `akanjs/server`: `ProcessMetricsCollector.startReporting(report)`, which reports once and then on every memory-log
2296
+ interval and returns the timer.
2297
+ - Subpath exports `akanjs/server/artifact/routeSeedIndexStore` and `akanjs/server/artifact/routesManifestStore`, so a
2298
+ published `@akanjs/devkit` can read and write those artifacts without loading the whole server barrel.
2299
+
2300
+ - 5f53462: fix(cli): scaffolds name their guards, and the mobile and package commands do what they say
2301
+
2302
+ Guards and templates:
2303
+
2304
+ - The module scaffold's slice is `{ root: Admin, get: Public, cru: Admin }` when the workspace mounts libs/shared,
2305
+ and `{ root: None, get: Public, cru: None }` — closed until you name a guard — when it does not; its sample slice
2306
+ and the sample app's `inPublic` take `init({ guards: [Public] })`. `cru: Public` is gone.
2307
+ - `akan add-mutation` / `add-slice` write `mutation(Boolean, { guards: [None] })` / `init({ guards: [None] })` and
2308
+ import `None`, and their surface step returns a review note instead of stopping as unsupported.
2309
+ - The sample app's `SignedIn` admits only a caller with an identity (`id`, or libs/shared's `self` / `me`); a guest
2310
+ carries an account object too and used to pass. `CurrentUserId` reads the same three places, and the sample's
2311
+ task fixtures sign a user in through libs/shared when it is installed. Its spec holds no assertions.
2312
+ - Every scaffolded abstract has the current shape — a title, one sentence and `## Rules` — instead of six headings
2313
+ of placeholder text: `create-module` writes `# <model> Abstract` with its guard and soft-removal rules,
2314
+ `create-service` writes `# <service> Service Abstract` and `create-scalar` `# <scalar> Abstract`, each with a
2315
+ placeholder sentence and two placeholder rules. The sample app's task, noti and workHistory abstracts state their
2316
+ real invariants instead of describing the module layout. The dead `module/__model__.abstract.md` template is
2317
+ removed.
2318
+ - `create-crud-page` imports from the module's own client (`@libs/<lib>/client` for a lib module), and
2319
+ `create-package` writes a tsconfig that extends the workspace root from any depth and references no missing file.
2320
+ - `akan add-field --default now` (and every Date default) writes a thunk, `() => dayjs()`.
2321
+
2322
+ CLI and mobile:
2323
+
2324
+ - iOS usage-description keys map to Apple's real Info.plist keys (`NSPhotoLibraryAddUsageDescription`,
2325
+ `NSPhotoLibraryUsageDescription`, both location-always keys) instead of `NS` + the option name.
2326
+ - A non-main server's `assetlinks.json` also lists `<appId>.debug`, so a debug build's App Links can verify.
2327
+ - `start-ios` takes `--no-allow-provisioning-updates` (short flag `-a`); every boolean option that defaults to
2328
+ `true` now accepts `--no-<name>`.
2329
+ - `akan update` installs `@akanjs/cli@<tag>`, the package that provides the `akan` binary.
2330
+ - `sync-package` reads package dependencies again (the tsconfig paths start with `./pkgs/`).
2331
+ - The quality scanner no longer recommends the nonexistent `akan compact`; `AbstractDoc.compactMinLines` is gone.
2332
+ - `--role` lists `federation`, and `--since` names its units.
2333
+
2334
+ - 8c31d54: fix: `akan code` says when compaction failed, leaves the Claude Code-sized buffer, and resolves its default model
2335
+
2336
+ **A failed compaction read as a successful one.** The engine reports a compaction that failed or was cancelled on
2337
+ the same `compaction_end` event as one that worked, with `errorMessage` and `aborted` beside the reason. The wire
2338
+ kept only the reason, so every end printed "Compacted the conversation" — including an automatic compaction that
2339
+ had failed, which nobody else reports, leaving the next sign of it an overflow error turns later. The `compaction`
2340
+ frame now carries `error` and `aborted`, and the transcript prints the engine's own sentence as a warning, or
2341
+ "Compaction cancelled.". A failed `/compact` still reports once, through the caller it threw to.
2342
+
2343
+ **The buffer is 32,768 tokens, up from the engine's 16,384.** On a million-token window that moves the threshold
2344
+ from 98.4% to 96.7% — where Claude Code compacts. The reserve is all the room the response at the threshold gets
2345
+ (the request clamps its output to the window less the prompt less 4,096), so at 16k a reasoning model ran out
2346
+ mid-thought there and the recovery re-sent a near-full window to retry. It is also the summary's output cap at 80%.
2347
+ The small-window warning now reads the settings the session actually runs with rather than the engine's defaults.
2348
+
2349
+ **The default model is `deepseek/deepseek-flash`.** `deepseek-v4-flash` is gone from both the engine's catalogue
2350
+ and DeepSeek's own model list, so the default resolved to nothing and `akan code` silently ran on whichever model
2351
+ sorted first among those with a key.
2352
+
2353
+ - ccf2ae2: fix: the server compresses only whole, modest bodies
2354
+
2355
+ `compressResponse` buffered and brotli- or gzip-compressed any body of a compressible type on the event loop, whatever
2356
+ its size, and answered a `Range` request with the whole body compressed. It now leaves alone a request with a `Range`,
2357
+ an answer that is partial (`206`, `Content-Range`) or not a success, and one whose `Content-Length` is over 4 MiB
2358
+ (compressing blocks the loop about 6 ms per MiB with brotli, 12 with gzip). The gateway keeps an upstream's
2359
+ `Content-Length` unless it had to drop the upstream's encoding, so the limit holds behind it too.
2360
+
2361
+ - 5f53462: fix(console): a production `akan console` brings services up and starts no schedule
2362
+
2363
+ The `console.js` an `akan build` writes never marked its process as a console, so `server.start()` took the replica
2364
+ branch: it registered every `interval` / `cron` / queue worker, ran every internal `init()`, and opened the log
2365
+ transport — a second scheduler inside a running deployment. The shim now marks the process before it loads the
2366
+ server, the same way `akan console` does in development, so a console runs service and adaptor `onInit` and nothing
2367
+ the internals schedule.
2368
+
2369
+ `assertAkanConsoleAllowed()` was handed `server.env`, which carries neither `environment` nor `operationMode`, so it
2370
+ effectively checked `NODE_ENV` alone. Called with no argument it now reads the deployment from `AKAN_PUBLIC_ENV` /
2371
+ `AKAN_PUBLIC_OPERATION_MODE` (deriving the mode the way `getEnv()` does): a `main` environment, a `cloud` or `edge`
2372
+ operation mode, or `NODE_ENV=production` refuses the console unless `AKAN_CONSOLE=1`.
2373
+
2374
+ - 5f53462: fix(signal): the cross-site gate compares hosts, so a TLS-terminating proxy no longer refuses every mutation
2375
+
2376
+ An app reached through a Cloudflare tunnel — or any edge that terminates TLS and forwards no
2377
+ `x-forwarded-proto` — answered `403 This request was not permitted.` to every mutation. The browser sends
2378
+ `Origin: https://app.example.com`; the request reaches the server over plain HTTP, so the origin it computed for
2379
+ itself was `http://app.example.com`, and the full-origin comparison made the app cross-site to itself.
2380
+
2381
+ `CrossSiteGuard.assertOrigin` now compares the `Origin` header's **host** against the host the request arrived on
2382
+ (`x-forwarded-host`, else `Host`), which is the comparison `McpRouter` already made for the same reason. The CSRF
2383
+ property is unchanged: the host is written by the browser from the URL it was told to open, so a page on another
2384
+ site still arrives with this deployment's host and its own `Origin`. Only the scheme distinction is dropped — the
2385
+ one part of an origin a proxy routinely loses — and what that costs is a page served over plaintext on the same
2386
+ host, which is an attacker who already holds the name. An explicit `:443` in a forwarded host and its absence in
2387
+ `Origin` now compare equal too.
2388
+
2389
+ A refusal logs the host it was measured against (`... (request host app.example.com)`), so a proxy misreporting
2390
+ the host reads differently from a missing `allowedOrigins` entry and from an attack.
2391
+
2392
+ - ccf2ae2: fix(dev): a CSR dev save reaches the open tabs before `app.js` is rewritten, and a tab opened in between boots once
2393
+
2394
+ A dev CSR save used to rewrite the whole `app.js` before telling the open tabs about the patch, although only a tab
2395
+ that boots later needs it. The patch is now announced first and `app.js` follows. The manifest records which
2396
+ generation `app.js` holds (`appGeneration`), and the server holds a booting tab's `app.js?g=N` for up to two seconds
2397
+ until the file holds `N`, instead of booting it one generation behind and reloading it again. A build that died in
2398
+ that gap leaves `app.js` behind; the next update rewrites it even when nothing else changed.
2399
+
2400
+ - ccf2ae2: perf(dev): a CSR dev save is patched by the resident builder, without spawning a build worker
2401
+
2402
+ - The dev builder keeps the CSR registry's graph, manifest and module factories in memory and compiles a save itself.
2403
+ A save no longer pays for a build worker's spawn and imports (about 130ms on minimal) or a pass over every module
2404
+ file, and `app.js` is rebuilt from memory after the patch is announced.
2405
+ - A save that needs a whole-app build still goes to a build worker, which exits and returns its memory: a first build,
2406
+ a config or signal/dictionary change, a new npm module, and an import no recorded resolution answers.
2407
+ - The builder works in two lanes. A save's codegen and CSR patch run in one, and the file watcher waits only for it;
2408
+ build workers, route builds and client-entry discovery run in the other, where a batch queued behind another folds
2409
+ into it. Consecutive saves no longer wait behind each other's pages and css builds, and css now builds before pages.
2410
+ - `AKAN_DEV_CSR_PATCHER=off` sends every CSR save to a build worker as before. A builder that dies mid-patch turns the
2411
+ patcher off until the dev server next replaces its builder and backend together (a config, signal or dictionary
2412
+ change), and says so.
2413
+
2414
+ - ccf2ae2: fix: a CSR page on another origin loads stored file URLs from the server
2415
+
2416
+ A file row keeps its URL relative to the server that wrote it (`/api/localFile/getBlob/...` with blob storage). A
2417
+ native shell serves the page from `app://localhost` or `https://app.localhost`, so the relative URL resolved against
2418
+ the app and the image never loaded. `resolveServerUrl` (`akanjs/client`) resolves an API-prefixed relative URL
2419
+ against `serverHttpUri` when the page's origin is not the server's, and leaves every other URL alone. `CsrImage`
2420
+ (so `Image` in a CSR bundle), agent attachments, and `libs/shared`'s editor images, videos, mention avatars, file
2421
+ download links, file gallery and Excalidraw images use it; Excalidraw stores the URL relative again.
2422
+
2423
+ - ccf2ae2: A CSR page renders when the app's root layout sits in a route group, inside the layouts that wrap it
2424
+
2425
+ - An app whose root layout is `page/(app)/_layout.tsx` rendered an empty `#root` on every CSR page — `?csr=true`,
2426
+ `/__csr` and native builds — with nothing in the console: the CSR route table left a route group's layout out of the
2427
+ root, so no route had a root layout, while the SSR route tree rendered the same pages fine.
2428
+ - The CSR route table and the SSR route tree now place layouts through one rule: the root layout is the first root
2429
+ boundary the page generator writes a `__root_layout` for, the one that carries `System.Provider`. Every layout below
2430
+ it — `(app)/(public)/_layout.tsx`, a nested boundary such as a `(tab)` group's — wraps the pages. In CSR that is what
2431
+ keeps a lock screen or a nav in `(public)/_layout.tsx` around the page instead of in the provider's hidden container.
2432
+ SSR renders the same element tree as before; those layouts' route segments are now keyed as layouts, not roots.
2433
+ - A route that still ends up with no root layout throws `[csr] no root layout for <path>` instead of rendering nothing.
2434
+
2435
+ - ccf2ae2: fix(csr): an entry of the same route shows its own content, the first page is cached, and a page nobody sees stops
2436
+ its effects
2437
+
2438
+ - **Same route, new entry, new content.** `/item?id=1` → `/item?id=2` kept showing the first entry: `RenderLayer`
2439
+ resolved a route's async render once per mount. It now renders again when an argument the route declares with
2440
+ `.param()` / `.search()` changes, layouts included, and keeps the previous result on screen until the new one lands.
2441
+ - **The page a session opens on is cached** like any page reached later. The history it seeded never put it in
2442
+ `cachedLocationMap`, so it unmounted two steps away.
2443
+ - **Every page container but the current one is `inert` and `aria-hidden`,** frame slot targets included, so Tab focus,
2444
+ a screen reader and the agent's `readScreen` no longer reach the page under the current one.
2445
+ - **A page nobody sees keeps its state but stops its effects.** A cached page, and the page a transition-less switch
2446
+ left (tab to tab) once it settled, render inside React's `<Activity mode="hidden">`: DOM and state stay, intervals
2447
+ and subscriptions stop, and both come back with the page. The page under an animated transition stays live, since a
2448
+ swipe back shows it.
2449
+ - `idxMap` and `scrollMap` are keyed by the entry's `href` on both sides. `CSR.tsx` read them by pathname, so the page
2450
+ under a query-string entry lost its stacking order.
2451
+
2452
+ - 5f53462: `akan start` sizes its boot wave against the machine instead of always booting one app at a time.
2453
+
2454
+ - With no `--concurrency`, the wave is `min(apps, half the memory budget / ~1.8GB per app, cores / 4)` and never
2455
+ below one, so a laptop boots its apps together while a small container still staggers them — which is the case
2456
+ the old default of 1 existed for. 1.8GB is a cold boot's peak: the SSR registry's boot build adds a build worker
2457
+ to every boot, and apps/akan's whole dev host peaks at about 1.76GB.
2458
+ - The next wave starts once each app of the previous one serves and its builder's boot builds have settled — the SSR
2459
+ registry's, and CSR's when `AKAN_DEV_CSR_REBUILD=1` arms it — or 30 seconds after it serves. The app shows as ready,
2460
+ and `--open` opens it, as soon as it serves.
2461
+ - The memory budget is the smaller of the host's RAM and `AKAN_MEMORY_LIMIT` / the cgroup limit. `os.freemem()` is
2462
+ not consulted: it counts free pages rather than reclaimable ones and reports ~0.3GB on an idle 48GB laptop, which
2463
+ would pin every machine to one app at a time.
2464
+ - The session prints the wave size and what set it, so a boot that still waits app by app says why.
2465
+ - `--concurrency <n>` is unchanged as an override and is clamped to the number of apps selected.
2466
+
2467
+ - ccf2ae2: fix(dev): dev documents skip the browser's HTTP cache, the HMR socket answers only its own origin, and superseded server bundles are removed during a session
2468
+
2469
+ - Under `akan start`, SSR pages and the dev CSR shell are sent with `Cache-Control: no-store`, so going back to a
2470
+ page no longer replays a document rendered by a build the server has moved past. `akan build` output is unchanged.
2471
+ - `/_akan/hmr` refuses an upgrade from a page on another origin, by the rule mutations already follow (the serving
2472
+ host, the native shells, and `allowedOrigins`): its messages carry build errors and the names of the files being
2473
+ edited, which a `--share` visitor's page or another site open in the same browser could otherwise read.
2474
+ - A render error's overlay clears once the page renders again, instead of staying on every tab until it reconnects.
2475
+ The HMR socket's reconnect backoff starts over at the server's hello rather than when the socket opens, so a
2476
+ gateway whose upstream keeps failing is no longer asked every 250ms.
2477
+ - A pages build that changes server output no longer leaves the bundle before it in `.akan/artifact/server` for the
2478
+ rest of the session (8MB on minimal, 21MB on apps/akan): each time the RSC worker takes a new bundle, the ones it
2479
+ has moved past that are over a minute old are removed, so a burst of saves leaves its bundles only until the next
2480
+ server-side save after it. A CSS change removes the stylesheets no tab can still link to the same way.
2481
+ - `getAkanHmrPhase()` and `isAkanHmrApplying()` from `akanjs/common` are deprecated: nothing sets the phase any more,
2482
+ so they always answer `null` and `false`.
2483
+
2484
+ - ccf2ae2: fix: a dev build of the iOS, Android or desktop app waits for a page the dev server is still building
2485
+
2486
+ The native dev gateway served with Bun's default 10 s idle timeout, so the first request for a page the dev server
2487
+ was still building (27 s for a small app on a Windows VM) was closed under the app, which then fell back to its
2488
+ bundled files: a dev build carries none, and the window stayed blank until the next reload. The gateway now keeps
2489
+ the request open until the dev server answers.
2490
+
2491
+ - ccf2ae2: feat(dev): a dev save now says where its HMR latency went
2492
+
2493
+ - Each save carries epoch-ms marks from the file watcher to the page: the first fs event and the watcher's flush,
2494
+ the builder picking the batch up, the build worker's spawn, start and imports, the update on disk, the send, the
2495
+ backend's broadcast, and the page receiving and applying it. The page keeps the last 64 in `__AKAN_HMR_TRACES__`.
2496
+ - `AKAN_DEV_WATCH_DEBOUNCE_MS` sets how long the watcher collects changes before it hands a batch over.
2497
+
2498
+ - 69f7178: fix(dev): `akan start` is the dev server even when the environment says `NODE_ENV=production`
2499
+
2500
+ An ambient `NODE_ENV=production` turned every route of the dev server into a 500:
2501
+
2502
+ ```
2503
+ [SSR] render failed scope=/en/: [SSR] route /:lang missing from production artifact — rebuild with `akan build` to include it
2504
+ ```
2505
+
2506
+ `WebRouter` picked its mode from `NODE_ENV` alone, so it installed the production route cache — the one whose
2507
+ `buildRoute` throws because `akan build` was supposed to have written a routes manifest already. A dev artifact
2508
+ never has one, so nothing could render, and the same ambient value also sent the gateway's runtime directory to
2509
+ `<workspace>/runtime` and baked production React into the dev bundles.
2510
+
2511
+ The value does not have to be exported to arrive: Bun auto-loads the workspace `.env`, so a `NODE_ENV=production`
2512
+ line in a downloaded env file reaches the CLI while `env | grep NODE_ENV` in the shell stays empty. A CI image
2513
+ default or a container base image does the same.
2514
+
2515
+ **The command now outranks the environment.** `akan start` pins `NODE_ENV=development` for the processes it
2516
+ spawns and for the in-process builder that bakes the value into dev bundles; `AkanApp` resolves a child's
2517
+ `NODE_ENV` from `AKAN_COMMAND_TYPE` before falling back to the inherited value; and `WebRouter` refuses the
2518
+ production branch outright while dev-hosted, warning once that it did rather than failing every request. Running
2519
+ a built artifact (`bun main.js`, Docker) is untouched — it carries no command type and keeps honouring
2520
+ `NODE_ENV`.
2521
+
2522
+ - 69f7178: fix(server): refuse the boot when two owners claim one `use` key or adaptor `refName`
2523
+
2524
+ Both registries were plain last-write-wins. A `use` key two libs both declared, or an adaptor `refName` two
2525
+ classes both carried, silently kept the second: the first value was gone and the replaced adaptor's `onInit`
2526
+ never ran. Neither left a trace, and an app whose `option.ts` shadowed a lib's key looked like it worked.
2527
+
2528
+ Registering either twice now fails `DiLifecycle` construction, naming the key and both claimants:
2529
+
2530
+ ```
2531
+ [DI:adaptor] 1 duplicate registration(s):
2532
+ • "imageStorage" is registered by service "article" and by service "gallery"
2533
+ ```
2534
+
2535
+ The check is per key, not per registration. One adaptor class reached from two services is one adaptor and
2536
+ passes; a predefined role rebound with `applyAdaptor` is one class and passes. Only two _different_ classes under
2537
+ one `refName` clash — which is the case that used to defeat an override silently, because the collected class won
2538
+ the map while the role kept pointing at the other one.
2539
+
2540
+ `AkanOption.getUses(env)` returns `[key, value][]` instead of a merged record, so a key declared twice inside one
2541
+ option is caught the same way as one declared across two libs. Nothing outside the framework calls it.
2542
+
2543
+ - 69f7178: fix(dictionary): restore the translate keys an extending dictionary inherits from the lib it extends
2544
+
2545
+ `ModelDictInfo` briefly carried an extra generic parameter, placed before `ErrorKey` and `EtcKey`. The three
2546
+ positional lists that merge two dictionaries — `AnyModelDictInfo`, and both halves of `MergeTwoModelDicts` — were
2547
+ not extended with it. Every parameter has a default, so supplying one too few is legal TypeScript: inference
2548
+ shifted by one slot and the last parameter fell off the end into its default.
2549
+
2550
+ For an app dictionary built as `modelDictionary(["en", "ko"], ...setting.dictionaries)`, that meant:
2551
+
2552
+ - `EtcKey` (the `.translate()` keys) became `never` — `l("setting.updateSuccessMsg")` for a key the _lib_
2553
+ declared stopped typechecking, in an app whose own source had not changed
2554
+ - `ErrorKey` was read one slot early, so `${refName}.error.${ErrorKey}` was built from the translate keys instead
2555
+
2556
+ Only the types were wrong. `modelDictionary` returns the base instance and the extending chain mutates it, so
2557
+ every entry was still there at runtime — which is why the failure surfaced only as a typecheck error in
2558
+ downstream workspaces, and why re-declaring the key in the app (the obvious workaround) silenced it while
2559
+ duplicating a translation the lib already owned.
2560
+
2561
+ Both a runtime test (an extending dictionary keeps the base's `translate`/`error` entries) and type assertions
2562
+ over the merged `EtcKey` now cover it, and the positional lists carry a note that adding a parameter means adding
2563
+ it in all three places.
2564
+
2565
+ - f882bbc: Resolve a dictionary key whose enum value contains a dot, so `l("llmModel.gpt-5.6-terra")` returns its label
2566
+ instead of the key itself.
2567
+
2568
+ Enum entries are stored under the value verbatim — `llmModel["gpt-5.6-terra"]` is one object key — but every
2569
+ reader went through `pathGet`, which splits the whole key on `.` unconditionally. A value carrying a dot was read
2570
+ as `llmModel → gpt-5 → 6-terra`, so it missed: `l()` and `l._()` rendered the raw key on screen (enum labels in
2571
+ `Field` selects, the constant and signal doc pages), and `DictionaryLookup` returned `undefined`, dropping the
2572
+ description from the MCP catalogue and the OpenAPI document. `<value>.desc` missed the same way. Nothing warned,
2573
+ because `TransMessage` builds the key union as `` `${refName}.${value}` `` and typechecks it fine.
2574
+
2575
+ The three readers now use `pathGetLoose`, a new `akanjs/common` export that tries joined prefixes when a plain
2576
+ segment walk finds nothing. It tries the shortest prefix first, so every key that resolved before resolves to the
2577
+ same node — only keys that used to fall through to the fallback are newly found.
2578
+
2579
+ - 2d8de1c: fix(ui): a disabled `Link` renders its `div` without the link-only props
2580
+
2581
+ With `disabled` (or no `href`), `Link` spread every remaining prop onto the `div` it renders, so `scrollToTop`,
2582
+ `replace`, `activeClassName`, `activeExact` and `noCache` reached the DOM and React warned about each. They are now
2583
+ dropped there; the caller's own attributes (`aria-*`, handlers, `data-*`) still reach the `div`.
2584
+
2585
+ - 69f7178: feat(akan): serve the framework documentation over MCP from the docs app
2586
+
2587
+ `apps/akan` now exposes its own generated documentation corpus as MCP tools, so an agent working in an Akan
2588
+ workspace can read the framework's docs instead of guessing at its APIs. Three tools, all `[Public]` — the same
2589
+ markdown is already served anonymously at `/llms/pages`, so a guard there would protect nothing while making the
2590
+ tools unusable to the agents they exist for:
2591
+
2592
+ - `listDocPages` — the whole index, optionally narrowed to one of the four sections.
2593
+ - `searchDocPages` — every given word must match, ranked with title hits ahead of body mentions.
2594
+ - `readDocPage` — one page in full as markdown, code examples included.
2595
+
2596
+ This is also the first app in the repo to serve a live `/mcp` catalogue, which is what makes the MCP boot log,
2597
+ the wire, and the refusal paths exercised by something other than a test.
2598
+
2599
+ The corpus under `public/llms/pages` is stale relative to the docs routes — regenerate with
2600
+ `bun apps/akan/script/generateLlms.ts` to pick up the MCP cheatsheet page and the mobile cheatsheets.
2601
+
2602
+ - 5f53462: The recovered-form banner is the app's to size and to skin.
2603
+
2604
+ - `Model.EditModal` takes `draftBarClassName`, so a dense screen — an editor sitting right under its own header —
2605
+ can trim the banner's padding and type without forking the shell.
2606
+ - `DraftBar` is an override slot, bound like any other in a `page/**/_overrides.tsx` manifest. The shell keeps the
2607
+ draft state and keeps publishing the restore and discard tools, so a replacement takes `state`, `savedAt`,
2608
+ `onRestore` and `onDiscard` as props instead of reaching into the store under string keys.
2609
+ - A draft the record has caught up with is dropped rather than offered back. A form that saves itself as the user
2610
+ types moves `updatedAt` with its own save, so every reopen read as a conflict and asked the user to settle a
2611
+ difference that was not there.
2612
+ - `Model.Edit` forwards `draft` the way `Model.New` already did, so a form opened through it can turn recovery off
2613
+ at all.
2614
+
2615
+ - 22aa04f: fix(ui): keep a Modal opened from a Dropdown menu alive
2616
+
2617
+ `Dropdown` dismissed on any `mousedown` outside its own DOM subtree, but `Dialog/Modal` leaves that subtree
2618
+ through `createPortal(document.body)`. A `Model.Edit` (or any Modal) rendered as a menu item therefore read
2619
+ every click inside itself as an outside click: the menu closed, its content unmounted, and the modal it had
2620
+ opened went with it — without an animation, and without running `onCancel`. Because `EditModal` derives its
2621
+ openness from the store rather than local state, the `<model>Modal === "edit"` left behind then re-opened
2622
+ the editor by itself the next time the menu mounted its content.
2623
+
2624
+ React context reaches through a portal where the DOM does not, so ownership is now explicit rather than
2625
+ inferred from the DOM: `Dropdown` hands its content a scope, and a `Modal` stamps whichever scope rendered
2626
+ it onto the roots it portals out (`data-akan-overlay`). A dismiss check then recognises the overlays it
2627
+ owns — including ones opened by a nested scope — and ignores clicks inside them, while an overlay it does
2628
+ not own dismisses it as any other outside click would.
2629
+
2630
+ Four changes:
2631
+
2632
+ - Dismiss checks ignore clicks inside an overlay the dismissing scope owns. A dropdown rendered _inside_ a
2633
+ modal is unaffected, since that modal is not its own — it still dismisses on an ordinary click in it.
2634
+ - The menu is hidden rather than unmounted once it has been opened, so an overlay a menu item opened
2635
+ survives the menu closing.
2636
+ - `EditModal` resets the modal it owns when it unmounts while open, whatever tore it down. The reset is
2637
+ deferred one microtask so a remount in the same commit (a re-keyed list row) claims the modal back instead.
2638
+ - A menu item marked `data-dropdown-keep-open` no longer closes the menu, so a switch or copy button inside
2639
+ a menu stays usable.
2640
+
2641
+ Both `Data.ListContainer` and `Data.Item` put action lists straight into a `Dropdown`, so list and item
2642
+ toolbars were affected the same way. An app-authored overlay that portals itself can join the same
2643
+ bookkeeping by spreading `useOverlayLayerProps()` onto its portal roots.
2644
+
2645
+ - 69f7178: fix(package): embed `use-agentic` into the akanjs build instead of depending on it
2646
+
2647
+ `use-agentic` is a workspace package, not a registry entry — it is not in `publishableAkanPackages` and
2648
+ `release:build-packages` never builds it — so the `"use-agentic": "0.1.0"` line the dependency scanner wrote into
2649
+ `pkgs/akanjs/package.json` pointed every consumer at something `bun install` cannot resolve.
2650
+
2651
+ The akanjs build now copies its source into `vendor/use-agentic` inside the dist and rewrites every
2652
+ `from "use-agentic"` specifier to the relative path, in the shipped `.ts` sources and in the emitted `.d.ts` tree
2653
+ alike — declarations for the embedded package are emitted into `types/vendor/use-agentic` by a second program, so
2654
+ the published types resolve without the package existing anywhere. `PackageRunner` treats it as a bundled runtime
2655
+ dependency the way `@akanjs/cli` already treats `@akanjs/devkit`, which keeps it out of both the dist and the
2656
+ source `package.json` on every rebuild.
2657
+
2658
+ `@happy-dom/global-registrator` moved to an optional peer dependency alongside `@playwright/test` and `chance`,
2659
+ the shape every other package the `test/` facet reaches already has. `test/registerDom.ts` is a value import, so
2660
+ the scanner had promoted a test-only DOM shim into the runtime dependencies of every app that installs akanjs.
2661
+
2662
+ Nothing changes inside the monorepo: framework source keeps importing `"use-agentic"` and keeps resolving it
2663
+ through the workspace. `GuideProps`, `ZoneProps`, and `AgentScopeProps` are now exported, because the `Agent`
2664
+ namespace object cannot have its declaration emitted while the props it infers cannot be named (TS4023) — that
2665
+ error had been failing `build-package akanjs` outright, since the release build runs the declaration emit with
2666
+ `AKAN_BUILD_DECLARATION_DIAGNOSTICS=error`.
2667
+
2668
+ - 69f7178: fix: the first field run's findings — request context, module identity, and an honest agent surface
2669
+
2670
+ Six fixes from running the in-page agent on a real product app, plus the one its own docs app hit.
2671
+
2672
+ **Server components see the request again.** The RSC worker wrapped a render in `requestStorage.run(...)` only,
2673
+ and the flight stream pumps components after that scope has exited — every `getSelf()` read no cookies and
2674
+ bounced authed pages to `/signin`. The worker now keeps a request fallback pushed until the render settles, the
2675
+ same discipline `ssrFromRscRenderer` already used. The stack is global and last-push-wins across concurrent
2676
+ renders; pumping the flight render inside the ALS scope itself is the follow-up that removes the caveat.
2677
+
2678
+ **`akanjs/fetch` is a vendor shared module.** It was bundled separately into the app and the vendored
2679
+ `akanjs/store` chunk, so `FetchClient`'s static serialized-signal registry split in two and the agent catalogue
2680
+ silently read the empty copy — every endpoint-named action refused as undescribed. The registry also moved to a
2681
+ `globalThis` symbol (the anchoring the shared client proxy already had), so a future duplicated module instance
2682
+ degrades to nothing instead of an empty surface.
2683
+
2684
+ **The relay's return scalar registers on the client graph.** `agentTurn` lived in `akanjs/signal`, which no
2685
+ client bundle loads, so the chat's first probe of `fetch.runAgentTurn` threw `No scalar constant model for
2686
+ agentTurn`. The scalar now lives in `akanjs/fetch` and `FetchClient` itself force-registers it — Bun links a
2687
+ barrel's `export *` modules lazily per used binding and its transpiler drops unused imports, so only a real call
2688
+ survives to do that.
2689
+
2690
+ **`labelOf` reads the `text: "title"` role as the `Set` it is.** It asserted `readonly string[]` and called
2691
+ `.find`, so every screen-context label on a search-indexed model threw.
2692
+
2693
+ **`akan build` relaunches itself once with the app env in place.** Bun macros snapshot `process.env` at process
2694
+ start, so the env the build resolves and publishes later is invisible to them — and any hand-written
2695
+ `@libs/<lib>/server` import (the sanctioned cross-lib entrypoint) puts that lib's env files, which read
2696
+ `getEnv()` at module scope, into the `getSerializedSignal` macro graph. `AKAN_PUBLIC_APP_NAME` is the one
2697
+ required key a workspace root `.env` cannot carry, so the CLI build now spawns itself again with it set and the
2698
+ child does the real build — the `AKAN_PUBLIC_APP_NAME=<app> akan build <app>` workaround is built in. The
2699
+ relaunch decision compares the env the process was **born** with, never the live `process.env`: the command
2700
+ dispatcher writes the resolved app name into `process.env` before any command method runs, which satisfies
2701
+ runtime readers but not macros — a guard reading the live value never fires (that inert shape is what alpha.19
2702
+ shipped). A `Relaunching with AKAN_PUBLIC_APP_NAME=…` line in the build output is the observable proof. The
2703
+ generated `sig`/`dict`/`srv`/`db` barrels also stopped walking parent _server_ barrels (they import the lib's
2704
+ own `@libs/<lib>/lib/<facet>` modules after `akan sync`), and `FetchClient.from` resolves its origin tolerantly,
2705
+ so a lib whose graph stays clean serializes with no env at all.
2706
+
2707
+ **The agent surface stops publishing two lies.** Form setters for the base document fields (`setIdOnX`,
2708
+ `setCreatedAtOnX`, `setUpdatedAtOnX`, `setRemovedAtOnX`) are no longer tools — the server stamps those. And an
2709
+ action named after an endpoint is published with the endpoint's schema only when it declares at least as many
2710
+ parameters: one declaring fewer would silently drop the borrowed tail and read stale form state, which shipped a
2711
+ resident SMS with the wrong unit number in the field run. Such an action is refused by name and warned once in
2712
+ the console; trailing extras beyond the endpoint's arguments stay legal, which is the generated
2713
+ `create<Model>(data, options?)` shape.
2714
+
2715
+ - 2d8de1c: fix(ui): component, client and projection fixes from the framework bug sweep
2716
+
2717
+ - **A server-rendered `Link` to a `#hash` keeps the caller's props** — `target`, `onClick`, `aria-*`, `data-*` —
2718
+ like every other `Link`.
2719
+ - **`Input.TextArea` attaches `inputRef` to the textarea** instead of spreading it onto the DOM, where React warned
2720
+ and the ref stayed empty.
2721
+ - **`System.Reconnect` removes the disconnect listener it added**, so remounts no longer stack listeners that each
2722
+ ping on every disconnect.
2723
+ - **`Agent.Context` renders on every environment except `AKAN_PUBLIC_ENV=main`**, like `Agent.Dock`; a
2724
+ shell-exported `NODE_ENV=develop` no longer hides it.
2725
+ - **The `LoadingArea` override slot takes `Loading.Area`'s props**, so `<Loading.Area className>` typechecks and an
2726
+ override can type its props.
2727
+ - **`System.Root` is flagged as deprecated where it is used**, not only on its props type.
2728
+ - **`useCodepush` no longer interrupts every release check with a debug `window.alert`.**
2729
+ - **`storage.removeItem` falls back to `localStorage` when the native Preferences call rejects**, as `getItem` and
2730
+ `setItem` already did.
2731
+ - **A projected read of a row written before a field existed gets that field's default the way a full read does**:
2732
+ an array gets its own fresh copy (pushing into it used to rewrite the model's default for every later document),
2733
+ and a required nested scalar gets its default instead of `null`.
2734
+ - **`logger.raw(msg)` writes the text as given**, like the static `Logger.raw`; it used to append a newline.
2735
+
2736
+ - 2d8de1c: fix(server): a replica keeps a pubsub room while any of its sockets is still in it
2737
+
2738
+ With two or more replicas (or under `akan start`), the gateway forwards a room's publishes to every replica that
2739
+ holds a socket in it. It tracked that membership per replica but applied each socket's unsubscribe to the whole
2740
+ replica, so when one of several sockets on a replica left a room, every other socket there stopped receiving
2741
+ publishes from the other replicas until the 30-second membership snapshot restored it. The gateway now removes a
2742
+ replica from a room only when the last of its sockets in that room leaves, and a snapshot keeps what it knows about
2743
+ the sockets of the rooms it confirms. A single-process deployment was never affected.
2744
+
2745
+ - 2d8de1c: fix(server): the gateway's `sockets` metric stops counting sockets whose room a snapshot dropped
2746
+
2747
+ The `sockets` count on `/_akan/app/metrics` is the number of sockets the gateway knows to be in at least one pubsub
2748
+ room. When a socket's unsubscribe never reached the gateway, the 30-second membership snapshot took the room away
2749
+ from the replica but left the socket counted, and every such socket stayed in the metric until that replica
2750
+ restarted. A room the snapshot no longer confirms now takes the sockets recorded in it along; a socket still in
2751
+ another room stays counted. Delivery never read this count, and the metric's name and shape are unchanged.
2752
+
2753
+ - f882bbc: Enforce `field(..., { immutable: true })` on the document write path instead of carrying it as decoration.
2754
+
2755
+ The option survived the v2 migration as metadata and nothing else. v1 handed it to mongoose, which enforced it;
2756
+ the SQL document layer that replaced mongoose never picked it up, so the only readers left were the schema doc's
2757
+ `immutable` pill and the devtools serializer. A field declared immutable was freely overwritten by
2758
+ `doc.set(...).save()`, by `srv.update(id, patch)`, and by every query-level write — while `akan`'s own docs
2759
+ promised "생성 후 수정할 수 없습니다".
2760
+
2761
+ `writeUpdatedDocument` is now the single gate: it compares each immutable field's prepared value against the row
2762
+ it loaded and throws when they differ. That one choke point covers the whole document path — `.set()` then
2763
+ `.save()`, a chain method assigning the field directly, and `update(id, patch)` — because all three land there.
2764
+ `.set()` itself stays a plain in-memory assign, so the throw arrives at `save()`, where the write actually
2765
+ happens; gating `.set()` too would have missed direct property assignment and gone silent on documents loaded
2766
+ through a read query, which carry no snapshot to compare against.
2767
+
2768
+ The check runs before the save hooks, so the message names the field the caller changed rather than one a
2769
+ `_preUpdate` derived from it, and it omits the values — an immutable field may also be `field.secret`.
2770
+
2771
+ Query-level writes are deliberately left open, mirroring the way mongoose exempts `bulkWrite`:
2772
+ `updateOneByQuery` / `updateManyByQuery`, the generated `update<Filter>` / `remove<Filter>`, and
2773
+ `updateById` / `removeById` compile straight to one SQL statement, fire no hooks, and read no existing row to
2774
+ compare against. A caller reaching for that path has already stepped outside document semantics. `create` and an
2775
+ upsert's insert are untouched, which is where an immutable field is meant to be set.
2776
+
2777
+ Re-saving an unchanged immutable field is not a change and does not throw, so `doc.save()` after editing anything
2778
+ else keeps working — including on a row written before the field was declared, which reads back as the field's
2779
+ default on both sides of the comparison.
2780
+
2781
+ - 69f7178: feat: component-level in-page agent — a chat that reads the rendered screen and drives it
2782
+
2783
+ The agent surface work so far described what an app _can_ do (the store catalogue, the MCP catalogue); this ships
2784
+ the half that knows what the _screen_ is doing and lets a user hand it the wheel. One mount —
2785
+ `<Agent.Chat />` in a layout — is the whole integration.
2786
+
2787
+ **`use-agentic`** is the new framework-independent core: a mount-lifetime surface
2788
+ (`registerTool`/`registerResource`/`openScope`/`registerGuide`, name stacks where the newest registration wins and
2789
+ unregistering restores what it shadowed), hooks (`useAgentState`/`useAgentTool`/`useAgentResource`/`useAgentGuide`,
2790
+ declarations mount-static and behavior always-latest), and `AgentSession` — the client-side conversation loop:
2791
+ send → model turn → tool calls → approval gate → execute → report resource diffs → next turn. The loop lives in the
2792
+ browser because the tools do; the server is one stateless turn relay that never executes anything. Failures land in
2793
+ the transcript rather than being thrown past it. The wire is documented in `WIRE.md` so any backend can serve it.
2794
+
2795
+ **The screen context is derived, not declared — and it follows the rendered screen, not the bundle.**
2796
+ `Load.Units`/`Load.View` register scopes and curated item lists as they mount (labels come from the `text: "title"`
2797
+ search role), `StoreInstance` counts which state keys the mounted components are reading — `st.use`, `st.sel`, and
2798
+ `st.ref` all count, the selector-based pair by running the selector once over a recording proxy — and a store's
2799
+ catalogued actions and state are published only while one of its keys is live. `AgentContext` assembles route +
2800
+ screen + live-state blocks per turn — primitives inline, everything else one masked `readState(key)` call away.
2801
+ `remove*` names default to a confirm gate, and the framework adds two built-ins beside `readState`: `navigate`
2802
+ (internal paths only, the same router `Link` rides) and `readScreen`, which serializes the rendered DOM into
2803
+ compact text — headings, links, control values with their `data-akan-*` annotations — so "what does this page
2804
+ say?" is answerable; the chat's own UI is skipped via `data-agent-ui` and a password value is never read.
2805
+
2806
+ **Exposure is the store author's to trim.** `static agent = false` keeps a whole store off the surface — the
2807
+ framework's base store declares it, which also stops `readState` from reaching the `tryJwt` credential — and
2808
+ `static agent = { exclude: [...] }` withholds named actions and state keys; `st.use.x({ agent: false })`
2809
+ subscribes without counting toward liveness. Generated `set<Key>` conveniences are no longer published at all:
2810
+ they carried no schema, so they listed as zero-argument levers that wrote `undefined`.
2811
+
2812
+ **`Agent.Chat`** is the user-facing half: launcher, transcript, composer, and the inline approval card, behind an
2813
+ `ssr: false` lazy boundary so none of it touches the server HTML, and re-skinnable through the `AgentChat`
2814
+ `_overrides.tsx` slot. Its default runner posts the wire to the app's own `runAgentTurn` route through
2815
+ `httpRunner`, which negotiates streaming via `accept`: the relay answers `text/event-stream` — one `RunnerEvent`
2816
+ per SSE `data:` line, assistant text arriving as it is generated — and a server that does not stream answers the
2817
+ same JSON turn. The endpoint reads the request with `.with(Req)` and returns a raw `Response` for the SSE half;
2818
+ `LlmAdaptor.chat` gained an optional `onDelta`, and an adapter that ignores it still answers whole. An app that
2819
+ mounts no relay degrades to a transcript message. `ThemeToggle` now publishes the current `theme` and a
2820
+ `setTheme` tool, so "switch to dark mode" works out of the box wherever it is mounted.
2821
+ A tool call is **one row** that resolves in place — pending, then `✓`/`✕` with the result's error or change count
2822
+ — rather than a badge on the assistant message and a second row for its result: the model needs both wire
2823
+ messages, but the user watched one thing happen, and the name appearing twice read as a doubled call. The row
2824
+ carries the call's arguments, because two searches are otherwise the same row twice.
2825
+ `<Agent.Guide instructions="..." />` layers route-scoped guidance by render position: nested Guides concatenate,
2826
+ navigation withdraws them. `prompt()` endpoints double as the chat's slash commands with no listing endpoint —
2827
+ the client reads its own serialized signals, and the prompt's GET enforces its guards at call time.
2828
+
2829
+ **The relay is framework-embedded.** `runAgentTurn`, the `agentTurn` scalar, `AgentTurnStream`, and the
2830
+ `AgentRelayAccess` guard ship with `akanjs` itself, registered the way the `base` module is — every app serves the
2831
+ relay with no lib to mount, `AKAN_AGENT=false` takes it off, and a lib that still carries its own `agent` module
2832
+ wins the refName so older workspaces keep working. `OpenaiLlm` (chat-completions REST, zero SDK dependencies)
2833
+ is the predefined default behind a new `LlmAdaptorRole`; an app swaps providers in its `option.ts` with
2834
+ `applyAdaptor(LlmAdaptorRole, OwnLlm)` — the same builder family as `applyMiddleware`, and the override mechanism
2835
+ works for every predefined adaptor role. The endpoint stays outside MCP — its `Any` bodies are refused from the
2836
+ catalogue — and `AgentRelayAccess` defaults to allow but now **warns at boot while no policy is registered**; a
2837
+ policy that throws fails closed.
2838
+
2839
+ **An app configures its server in `lib/option.ts`, not through the environment and not in `main.ts`.** `AkanOption`
2840
+ gained three setters, each read from every lib in mount order with the app's own last, so an app tightens what a
2841
+ library declared without restating it. `setLlm({ apiKey, model, host })` — or `setLlm((options) => …)` to take the
2842
+ key out of the app's own gitignored env object — reaches whichever adaptor holds `LlmAdaptorRole` as the
2843
+ `llmOption` use, replacing the per-provider environment names; the settings belong to the role
2844
+ rather than to one provider, so they survive a swap. `setAgentAccess(SignedIn)` names the guards
2845
+ `AgentRelayAccess` forwards to, which `AgentRelayAccess.use` takes at boot. `setMcp({ … })` carries the MCP server
2846
+ settings that `new AkanApp("./server", { mcp })` used to spell as child environment variables — the gateway there
2847
+ only spawns children, while `option.ts` is already handed to the process that mounts `/mcp`, so `AkanAppOptions.mcp`
2848
+ is gone. Every `AKAN_MCP_*` env spelling still works for a deployment configuring what the source does not, and an
2849
+ option written in code still wins over the env of the same name.
2850
+
2851
+ **`<Agent.Zone id="comments">` runs a second agent over one section, in parallel with the root.** Zones are views
2852
+ of the same surface, never walls: everything mounted inside — `st.use` subscriptions (liveness is now tagged with
2853
+ the ambient scope), hook tools, guides — belongs to the zone's own conversation _and_ stays visible to the root
2854
+ agent, so wrapping a section costs the root nothing. An `Agent.Chat` inside binds to the zone session
2855
+ automatically, a zone's `readScreen` reads only its own `data-agent-zone` container, and guides follow the layout
2856
+ cascade — ancestors and own, never a sibling's. The core grew `surface.view(path)` (`SurfaceView`), and
2857
+ `AgentSession` now runs over any view.
2858
+
2859
+ **`persist` keeps the transcript across reloads.** Off by default; `<Agent.Chat persist />` stores settled
2860
+ messages in sessionStorage (per-tab, gone when the tab closes), `{ storage: "local" }` outlives it, and each
2861
+ `Agent.Zone persist` keys its own entry by scope path. Restores drop the assistant draft a reload cut short,
2862
+ saves are debounced against streaming deltas, a versioned envelope discards stale wire shapes, only the newest 50
2863
+ messages are kept, and a full or blocked storage never breaks the chat. The chat header gained a clear button
2864
+ that empties both the transcript and the stored copy.
2865
+
2866
+ - f882bbc: Intern the in-page agent's React contexts on `globalThis`, so a zone's tools reach the zone's session.
2867
+
2868
+ An akan build inlines `use-agentic` into every client bundle that reaches it — four of them in this repo's own
2869
+ docs app — and a React context is identified by object identity. `AgenticSurface.shared` already survives that on
2870
+ a `Symbol.for` key; `SurfaceContext`, `ScopeContext` and `SessionContext` did not. So the `ScopeContext.Provider`
2871
+ an `Agent.Zone` rendered from one copy was invisible to the `st.tool` / `st.expose` / form tools reading it from
2872
+ another: `useScopePath()` fell back to `[]`, every declared tool registered at the root scope, and the zone
2873
+ session then filtered out all of them as belonging to a different view. The model was handed the built-ins and
2874
+ answered `Unknown tool` for tools the screen had published — with nothing thrown and nothing logged.
2875
+
2876
+ All three now go through one `sharedContext` helper, and a test fails if any module in the package makes a
2877
+ context the plain way: the plain call works in the monorepo, where there is one copy, and breaks only once
2878
+ bundled, so using the feature cannot catch it.
2879
+
2880
+ - f882bbc: Intern every framework React context on `globalThis`, so an override or a provider value cannot be lost to bundle
2881
+ chunking.
2882
+
2883
+ An akan build inlines each reachable module into every client chunk that reaches it — `client/sharedContext.ts`
2884
+ itself lands in two chunks of this repo's docs app — and a React context is identified by object identity. A
2885
+ Provider mounted from one copy is invisible to a consumer holding another, which reads the context default
2886
+ instead, silently.
2887
+
2888
+ `UiOverrideContext` was a plain `createContext`, so a route's `_overrides.tsx` bound its slots in the chunk
2889
+ holding the generated provider while overridable components in other chunks kept rendering the framework default.
2890
+ Nothing threw, and only _some_ slots were affected — which reads like a bug in the app's own component, not in
2891
+ the framework. The same shape had already cost a zone agent every tool its screen declared.
2892
+
2893
+ All ten of the framework's contexts now go through `sharedContext`, which is exported from `akanjs/client` so an
2894
+ app or lib can make its own the same way. A test fails if anything under `client/` or `ui/` calls `createContext`
2895
+ directly: the plain call works in the monorepo, where there is one copy of every module, and breaks only once
2896
+ bundled — so using the feature cannot catch it.
2897
+
2898
+ - 5f53462: perf: hydrate a model from one compiled plan per class, and build a Date field's dayjs on first read
2899
+
2900
+ `new cnst.X(raw)` and `set()` used to re-read the field map, resolve `getProps()` and dispatch on the field kind
2901
+ per field per instance, evaluated every default thunk before overwriting it, and wrapped every Date in a dayjs
2902
+ object — ~300 bytes and eight getter calls each — whether or not anything read it. A 1000-row listing paid 3.5ms
2903
+ and 1.7MB over the parsed JSON for that; it now pays 0.9ms and lands under the raw parse, because `HydrationPlan`
2904
+ compiles the field map once per class and a Date field keeps a native `Date` under a symbol slot, with the `Dayjs`
2905
+ its type promises built on first read and memoized per `Date`.
2906
+
2907
+ The accessors are on the prototype, so `Object.keys(model)` and `{ ...model }` no longer include Date fields.
2908
+ `"createdAt" in model`, `for...in`, `JSON.stringify` (via `toJSON`), `plainFieldsOf` (`akanjs/common`),
2909
+ `immerify` and `deepObjectify` all still do; copy a model with `new cnst.X().set(model)`. immer invokes the setter on
2910
+ the draft, so store writes stay copy-on-write.
2911
+
2912
+ Two hydration passes that never produced anything are gone as well: the browser no longer `structuredClone`s
2913
+ every query response — the request memo that copy protected exists only inside a request store — and
2914
+ `fetch.initX` builds its `xList` / `xInsight` instances only for a caller that reads them, which a route passing
2915
+ `xInit` down never did.
2916
+
2917
+ - 22aa04f: fix(ui): stop a Model chunk from repainting the page it opened over
2918
+
2919
+ `lazy()` returned the React lazy component with no boundary of its own, so a chunk that resolved after
2920
+ first paint suspended past it to the nearest boundary — the route. Opening a ticket modal or a `⋮` menu
2921
+ therefore replaced the whole page with the route's loading fallback, once per fresh load, until the chunk
2922
+ was cached. Portalling to `document.body` did not help: the React ancestor chain is unchanged.
2923
+
2924
+ `lazy()` takes `suspense?: boolean`, and only a flagged call site gets a `<Suspense>` of its own. The flag
2925
+ is opt-in because a boundary also changes server rendering: under the default `renderMode: "stream"` its
2926
+ subtree leaves the shell as `loading` and arrives later in the stream, which is right for an interaction
2927
+ shell and wrong for a page body that SEO snapshots, prerendering and pre-hydration E2E read out of the
2928
+ shell. Every existing call site keeps its exact code path.
2929
+
2930
+ The `Model` barrel turns it on for all fifteen exports, and gives `View` and `AdminPanel` a plain block
2931
+ to hold their height while the chunk loads. Those two are the only ones that occupy space of their own:
2932
+ the triggers wrap the caller's children, so a fixed block would put a slab where a small button belongs,
2933
+ and the modals render `null` until opened, so a block would appear where nothing ever shows.
2934
+
2935
+ The fallback is a host element and has to be. The barrel carries no `"use client"`, so a client component
2936
+ referenced from it resolves to `undefined` inside the thunk and the render throws "Element type is
2937
+ invalid", taking the whole page segment under the layout with it. Marking the barrel `"use client"` is
2938
+ worse still: `Model.*` becomes a real server→client boundary and apps that pass crystalized model
2939
+ instances as props fail serialization at boot. Plain markup crosses neither line.
2940
+
2941
+ `LazyOption.loading` also starts working on the default path, having been reachable only through
2942
+ `ssr: false` before.
2943
+
2944
+ - 69f7178: fix(sync): hold libraries to the root layout allowlist, and check a library's own `page/` tree
2945
+
2946
+ `apps/<app>/base` was refused by `akan sync`, `akan doctor` and `akan quality scan`; `libs/<lib>/base` passed all
2947
+ three. The rule lives in one file — `workspaceLayout.ts` exists precisely so the three tiers cannot disagree — but
2948
+ the call sites read it behind `exec.type === "app"` / `for (const app of context.apps)` / `segments[0] === "apps"`,
2949
+ so a library root was never compared to anything at all. A lib could carry a `base/`, a `helper.ts`, a stray
2950
+ `script/`, and every command stayed green.
2951
+
2952
+ Libraries now answer to their own allowlist, and it is the app's minus what only an app can use: no `main.ts`, no
2953
+ `capacitor.config.ts`, no `.akan` / `android` / `ios` / `mobile` / `script` / `secrets`, because a library is never
2954
+ booted or packaged. `index.ts`, `README.md` and `tsconfig.spec.json` are added, since a library ships as a package.
2955
+ `public/` and `private/` stay, because `syncAssets` symlinks them into each consuming app.
2956
+
2957
+ Three more places the same gate was one-sided:
2958
+
2959
+ - `akan doctor` walked `context.apps` only. It now walks libraries too and reports `lib-root-unknown-entry` beside
2960
+ the existing `app-root-unknown-entry`.
2961
+ - `akan quality scan` warned on an unexpected app root _file_ and nothing else — not a library root file, and not a
2962
+ root _folder_ on either side, which is the shape `base/` actually takes. It now reports all four
2963
+ (`akan.layout.{app,lib}-root-{file,folder}`). Its `lib/` facet rule kept its own copy of the allowlist and flagged
2964
+ a `<model>.signal.test.ts` that `akan sync` explicitly permits; both now read the shared helper, and the rule is
2965
+ renamed `akan.layout.lib-facet-file` so its name no longer collides with the library root it was never about.
2966
+ - A library's `page/` folder was validated only once an app opted into it with `syncPageLibs`. An app checks route
2967
+ filenames in `getPageKeys`, which a library has no equivalent of, so `libs/<lib>/page/Component.tsx` synced clean
2968
+ in the library it belongs to and failed in somebody else's app. `akan sync <lib>` now reports it as a scan
2969
+ violation, from the same rule `validatePageSourceFile` throws — split out as `getPageSourceFileViolation` so the
2970
+ reason is available where throwing is the wrong answer.
2971
+
2972
+ - 2d8de1c: fix(ui): mark `Link`'s `noCache` deprecated — it has never done anything
2973
+
2974
+ Neither renderer reads it: the server-rendered and CSR links navigate the same way with or without it, and
2975
+ `router.push` / `replace` take no cache option to hand it to. The prop is still accepted, so nothing breaks, but
2976
+ `<Link noCache>` now shows as deprecated in the editor. Drop it; to fetch a page fresh after a change, clear the
2977
+ route cache with `clearRscNavigationCache()` from `akanjs/client`.
2978
+
2979
+ - 5f53462: The LLM seam names a wire, not a vendor
2980
+
2981
+ Three provider classes shipped where there are only two wires, and the framework's own vocabulary had grown a
2982
+ per-vendor shape that nothing outside the framework could extend: `agent.error.deepseekRequestFailed`,
2983
+ `openaiRequestFailed` and `anthropicRequestFailed` were three dictionary keys an app-authored adaptor could never
2984
+ add a fourth to, and `LlmOption` was a closed bag, so an adaptor needing a region or a project id had to open a
2985
+ second config channel beside `setLlm`. `LlmAdaptorRole` and `applyAdaptor` were already the whole contract; what
2986
+ was wrong was how much the framework claimed to know about who fills it.
2987
+
2988
+ **`DeepseekLlm` is gone and `OpenaiLlm` is the default.** They spoke the same wire through the same
2989
+ `OpenaiDialect` and differed only in a host, a model name and a vision claim, so one host-pointed class covers
2990
+ DeepSeek, Groq, Together, OpenRouter, Ollama and a self-hosted vLLM as well as OpenAI. **An app relying on the
2991
+ old default must now name `model` and `host`** — `option.setLlm({ apiKey, model: "deepseek-v4-flash", host:
2992
+ "https://api.deepseek.com" })`. There is no default model, for the reason there never was one on `OpenaiLlm`: a
2993
+ model name ages out of a catalogue into a 404 at the first turn.
2994
+
2995
+ **`OpenaiLlm` claims vision for its default host and nothing else.** OpenAI's own endpoint takes image parts, so
2996
+ that is what it answers with no `host` set; a host the app named is a gateway the class knows nothing about, and
2997
+ it is text-only until `option.setLlm({ accepts })` says otherwise. Handing bytes to a model that cannot decode
2998
+ them kills the whole turn on a 400, where text-only degrades them to a note the model can repeat back — so the
2999
+ safe direction is the default and the claim is declared, not guessed.
3000
+
3001
+ **One refusal key, `agent.error.llmRequestFailed`, carrying `{ provider, status, reason }`.** `provider` is the
3002
+ hostname that refused rather than a brand name, because an adaptor pointed at a gateway would otherwise credit
3003
+ OpenAI for that gateway's answer. An adaptor an app wrote reports through the same translated sentence the
3004
+ shipped ones do.
3005
+
3006
+ **`setLlm` is generic, so `LlmOption` is a floor rather than the whole shape.** Whatever else it is handed
3007
+ travels to the role untouched: an adaptor declares its own interface extending `LlmOption`, reads it back with
3008
+ `use<MyLlmOption>()`, and its provider-specific settings ride the channel the built-in ones do.
3009
+
3010
+ Step-by-step migration: `akan guideline show workspaceRecipes` (or `get_guideline workspaceRecipes`),
3011
+ Recipe 8.
3012
+
3013
+ - 5f53462: A server-side loopback fetch follows the port the process was actually run with, so a container started with
3014
+ `PORT=80` renders instead of failing every page with `base.error.serverUnreachable`.
3015
+
3016
+ - `getEnv().serverPort` fell back to a literal `8282` on the server side, and only `akan start` ever set
3017
+ `AKAN_PUBLIC_SERVER_PORT`. A production image overriding the Dockerfile's `ENV PORT=8282` therefore bound one
3018
+ port and pointed the RSC worker's `fetch.*` at another; the SSR render failed on every route, with no other
3019
+ symptom than the transport error. When the origin resolves to `localhost` — the self-call — the port now comes
3020
+ from `PORT`. A `SERVER_HOST` naming another host is not a self-call and keeps the explicit port, and
3021
+ `AKAN_PUBLIC_SERVER_PORT` still outranks both.
3022
+ - `AkanApp` publishes the port it resolved as `PORT` to the replica it runs in-process and to every child it
3023
+ spawns, so `new AkanApp({ port })` reaches the tree the same way the env var does. In solo mode that is also
3024
+ what makes the option bind: `AkanServer` reads `PORT`, so an explicitly configured port was previously ignored.
3025
+ - `akan build` no longer publishes `AKAN_PUBLIC_CLIENT_PORT` / `AKAN_PUBLIC_SERVER_PORT` into the build process's
3026
+ own env. Bundling `define`s every `AKAN_PUBLIC_*` into a literal, so with `PORT_OFFSET` set the builder's dev
3027
+ port was baked into the artifact, where no deployment env could override it.
3028
+
3029
+ - f882bbc: fix: a masked field carrying a `text` role is a compile error, not a failed boot
3030
+
3031
+ **`field.secret`, `field.hidden` and `resolve()` no longer accept a `text` role in their options.** The role puts a
3032
+ plaintext copy of the value in the search mirror, so pairing it with masking publishes exactly what the masking
3033
+ hides — the class build has always refused it. But the refusal fired during module evaluation, which means the
3034
+ first sign of it was a backend that would not boot: three failed boots and then every route answering 503, with
3035
+ typecheck and lint silent the whole time, because the option type was the same one plain `field` takes.
3036
+
3037
+ Removing `text` from those three option types moves the refusal to the call site, where the fix is obvious and the
3038
+ editor shows it. The option type is a union, so the exclusion is distributive — a plain `Omit` over it would
3039
+ collapse to the keys its members share.
3040
+
3041
+ **The class-build throw stays as the backstop** and now names the way out ("Drop the text role, or make the field
3042
+ plain") instead of only what is wrong. It is still the only check that can catch the cross-file case, where a
3043
+ scalar's own field carries a role and a parent in another file masks it — the excess-property check cannot see
3044
+ an option object built elsewhere either.
3045
+
3046
+ - 5f53462: fix(mcp): `AKAN_MCP=false` keeps `/mcp` off whatever an `option.ts` configures
3047
+
3048
+ `setMcp()` with an object read a missing `enabled` as `true`, so any `option.ts` that only configured the surface
3049
+ switched it back on. `libs/shared` hands over its OAuth settings exactly that way, so every app mounting it served
3050
+ `/mcp` under `AKAN_MCP=false`. An object without `enabled` now leaves the switch where it was, and the env switch only
3051
+ ever narrows: `AKAN_MCP=false` (or `AKAN_PUBLIC_MCP=false`) turns the surface off even over an explicit `enabled: true`.
3052
+
3053
+ - 5f53462: fix(service): a `memory(...)` value that is a scalar or model class round-trips on both caches
3054
+
3055
+ `memory(Map, { of: SomeScalarInput })` typechecked and serialized through the constant, then handed the
3056
+ resulting object to a cache that holds a string, a number or a Buffer. The sqlite-backed `SolidCache` happens to
3057
+ JSON anything else on its own, so it worked there; Redis passes the value to `hset`, which coerces it to
3058
+ `"[object Object]"`. The same declaration meant two different things per deployment, and the failure only
3059
+ surfaced on read — so apps hand-encoded JSON into a `String` memory to get a structured value back.
3060
+
3061
+ A value whose declared type is not a primitive scalar now travels as JSON text in both directions, decided by
3062
+ the declaration rather than by the runtime value, so both adaptors store and return the same thing. A value that
3063
+ arrives already parsed — a row written before this change, or `SolidCache`'s own `json` type — is still read
3064
+ correctly.
3065
+
3066
+ - ccf2ae2: `akan start-desktop`, `start-ios` and `start-android` print a page's log once, at the level it was logged, and say a
3067
+ boot came up in one line
3068
+
3069
+ - A boot that comes up prints `cmdc ios ready · iPhone 16 · http://localhost:8283 · 12.4s` and nothing else; its steps
3070
+ (prepare, build, install, launch) are debug. A boot that fails prints every step it took ahead of the error. A native
3071
+ config warning still prints as it happens.
3072
+ - A page line reads `WARN [page:WsClient] WebSocket message process failed …`: stamped once by the CLI's logger, at
3073
+ the page's own level, with no `[default]` label, no empty line after it, and a multi-line message kept together. A
3074
+ page logger writes `[Name] message` at the console method of its level inside a native shell, and the desktop host
3075
+ and iOS tag each line `[page<+><#window> <level>]` for the CLI to read back.
3076
+ - Android forwards the page console through the bridge in a dev build too, so logcat carries each line at its level
3077
+ and a `console.error("%s", …)` substitution the way the devtools show it; iOS writes os_log at the page's level. An
3078
+ uncaught error forwards its message along with WebKit's frames, which carry none.
3079
+ - Frame tracing (`[akan:frame:…]`) is opt-in (`?akanFrameDebug=1` or `localStorage["akan:debug:frame"] = "1"`) and
3080
+ logs at debug, once per event; a mobile or desktop target no longer turns it on for every visibility change.
3081
+ - Lines no page or host wrote (the simulator, WebKit) and where the host's pages come from are debug; `--verbose` shows
3082
+ the former. `start-desktop` and friends ask a dev server whose builder idled out for its health, not its home page,
3083
+ so they no longer report it missing while it wakes.
3084
+
3085
+ - 5f53462: fix(service): a required nested scalar fills from its own field defaults instead of failing the write
3086
+
3087
+ `field(Coordinate)` on a model whose scalar carries a default for every one of its own fields still had to be
3088
+ written `field(Coordinate, { default: () => new Coordinate() })`, or `create({ name })` threw
3089
+ `Missing required field: location`. Every other layer already disagreed: `getDefault` recurses into a nested
3090
+ scalar, `HydrationPlan` constructs one, and `sampleOf` fills one — only the SQL write path treated an absent
3091
+ scalar as a missing value rather than a constructible one.
3092
+
3093
+ `prepareDocument` now applies the same rule `getDefault` does, in the same order — a parent-level `default`
3094
+ first, then `nullable` (an optional nested scalar stays absent), then the scalar's own defaults. A **relation**
3095
+ is unchanged and still fails closed: only the caller knows which row it names.
3096
+
3097
+ The read path had the matching hole. A row written before the field was declared carries no value for it, and a
3098
+ scalar has no primitive `DEFAULT_VALUE` to fall back on, so `decodeDocumentPayload` handed back `null` — which
3099
+ the next `save()` of that row rejected as `Field is not nullable`. It now constructs the same defaults, so an
3100
+ existing row migrates by being saved.
3101
+
3102
+ Array defaults are copied per document rather than shared. A field with no declared default is given `[]` at the
3103
+ class build, and both the store and `getDefault` handed that one array to every document filled from the model,
3104
+ so a `doc.tags.push(...)` landed in the field default and in every document created after it. The constant layer
3105
+ was already safe — `crystalize` copies an array on the way into an instance — and the document path now is too.
3106
+
3107
+ - c45ac6c: fix(lint): stop `//!` markers from shipping to the browser
3108
+
3109
+ Bun's bundler classifies `//!` and `/*!` as legal comments — the `@license` / `@preserve` class it must preserve
3110
+ for licence compliance — and keeps them through `minify: true`. Everything else goes: `//`, `/* */`, `//*`, `//?`,
3111
+ and `// TODO:` are all stripped. But the workspace `AGENTS.md` prescribes `//!` as in-code marker 4, "disabled or
3112
+ must-fix code", so the convention itself guaranteed that internal notes about broken and unfinished code shipped
3113
+ verbatim to every visitor. A deployed app served eight of them in one chunk, including `//!env로 옮겨야함`,
3114
+ `//!need to change`, and `//! Need to fix after bunjs migration`.
3115
+
3116
+ There is no bundler-side fix: `legalComments` is not a `Bun.build` option, and `Bun.Transpiler.transformSync`
3117
+ preserves `//!` too. The source is the only place to stop it.
3118
+
3119
+ `no-bang-comment-in-client.grit` now bans the marker in browser-reachable code — `ui/`, `webkit/`, `common/`,
3120
+ `page/**/*.tsx`, `*.constant.ts`, `*.store.ts`, and the five module component suffixes — while leaving it legal in
3121
+ server, `srvkit/`, and CLI files, which never reach a bundle. The five existing occurrences in `akanjs` and the
3122
+ libs became `// FIXME:`.
3123
+
3124
+ The rule anchors the marker to line start or to whitespace after code, so a literal like `"https://host//!path"`
3125
+ does not trip it. Comments are trivia rather than nodes, so the diagnostic spans the module and a marker on a
3126
+ file's very first line is invisible to the pattern — the one case the rule cannot see.
3127
+
3128
+ Rebuilding `apps/akan` takes the bundle from two `//!` comments to zero, with the third-party `/*!` licence
3129
+ blocks left intact.
3130
+
3131
+ - 2d8de1c: fix(oauth): read a Basic client credential whatever the scheme's case
3132
+
3133
+ `/oauth/token` and `/oauth/revoke` recognized HTTP Basic client authentication only when the scheme was spelled
3134
+ `Basic`. RFC 7235 makes an auth scheme case-insensitive, so a client sending `basic` or `BASIC` was read as sending
3135
+ no credential at all, and a confidential client was then refused. The scheme's case is the only thing that
3136
+ loosened: the single space after it, the decoding of the two halves and the one-channel rule are unchanged, so a
3137
+ lowercase `basic` header beside a `client_secret` in the body is now refused like a `Basic` one.
3138
+
3139
+ - ccf2ae2: `akan start` replaces its backend one restart at a time, so saves in quick succession can no longer leave a second
3140
+ gateway holding the port.
3141
+
3142
+ - A save whose restart came due while the previous one still waited for the old backend to exit used to signal the
3143
+ same process again and then spawn a second backend beside the first. The host kept the one that lost the port,
3144
+ restarted it on every save, and the untracked one went on answering. A restart that comes due now runs after the
3145
+ one in flight, and a config or metadata recycle and `akan start`'s shutdown wait for it too.
3146
+ - A gateway that cannot bind its port no longer reports itself ready while it waits for its replicas to exit.
3147
+
3148
+ - 22aa04f: fix(ui): give Escape to the topmost overlay, and size the Select panel to its options
3149
+
3150
+ `Modal` was the only overlay that closed on Escape, and it listened on `window` per instance — so two
3151
+ stacked dialogs both heard the same key and went down together. Escape now runs through one shared stack
3152
+ (`useEscapeKey`) that hands the key to the surface opened last, and `BottomSheet` and `Popconfirm` are on
3153
+ it too. A confirm popover inside a modal closes itself and leaves the modal standing.
3154
+
3155
+ `Select`'s open panel was a fixed `h-[270px]`, because `height` cannot ease to `auto` and the open/close
3156
+ transition needed something to animate. A three-option list therefore rendered with ~100px of dead space
3157
+ below it. The transition moves to `max-height`, which animates just as well and lets the panel end where
3158
+ its options do, and the always-on scrollbar becomes `overflow-y-auto`.
3159
+
3160
+ Also: the dialog surface takes `outline-none`. Focus moves onto it when the dialog opens so the tab order
3161
+ starts inside, but Chrome treats that programmatic focus as keyboard focus whenever the last interaction
3162
+ was a key — or a click on a non-button trigger — and drew a ring around the whole surface. Controls inside
3163
+ keep their own focus rings.
3164
+
3165
+ And `Tooltip`'s wrapper takes `w-fit`: `inline-flex` alone still stretches when the trigger is an item of a
3166
+ column flex container, and the bubble's `left-1/2` then centred on that full width, far from the trigger.
3167
+
3168
+ `Dropdown`'s panel loses the `gap-2` between rows and the stray `pr-3`, which together padded a three-item
3169
+ menu out to 132px of mostly empty space; rows now sit `gap-0.5` apart inside symmetric padding.
3170
+
3171
+ - f882bbc: fix: a `_overrides.tsx` manifest reaches what the root layout renders, and what an overlay host renders for it
3172
+
3173
+ **The override provider was mounted inside the root-layout stream, so the two most common positions a component
3174
+ is rendered from were beyond every manifest's reach.** Route rendering composes
3175
+ `[...renderRootLayouts, ...renderLayouts]`, and the override render rode the second stream — so a root-boundary
3176
+ layout wrapped the provider instead of sitting inside it. Anything such a layout renders beside `{children}` (an
3177
+ agent chat, a dock, a shell control) resolved every slot to its framework default, and so did anything drawn
3178
+ through the overlay host that layout mounts, which is where a portalled `Modal` renders. Both failed silently:
3179
+ the provider was mounted, with the right slot map, one level too deep.
3180
+
3181
+ Root boundaries are `/`, `/:lang` and `/:lang/<basePath>`, and a route **group adds no path segment**, so
3182
+ `page/_layout.tsx` and `page/(group)/_layout.tsx` are both root layouts — which is exactly where the framework's
3183
+ own guidance says to mount `<Agent.Chat />`.
3184
+
3185
+ **Overrides now wrap the whole stack, root layouts included.** `renderRootLayouts` is composed as
3186
+ `[...overrideRenders, ...rootLayoutStack]` and the CSR/mobile builder mirrors it. Nested manifests still stack in
3187
+ node order, so the closest declaration wins; the recursion carries the override-free root-layout array, because
3188
+ the root-boundary test counts its length. No route type or segment-identity change: overrides ride the stream the
3189
+ client already labels `"root-layout"`.
3190
+
3191
+ The regression tests assert the **resolved output** of a route whose root layout — and, separately, whose
3192
+ route-group layout — renders a slot-bound component. Asserting that the provider is an ancestor would not have
3193
+ caught this: it was.
3194
+
3195
+ - 2d8de1c: fix(release): the published packages no longer carry test-run files from `local/`
3196
+
3197
+ `akanjs` 3.0.0-beta.19 shipped 65 files (SQLite databases and logs, 820 KB) and `@akanjs/devkit` 2 from the
3198
+ gitignored `local/` directory the test suites write into, because both builds copied the package directory whole.
3199
+ `build-package` and `akanjs`'s own build now leave `local/` out, and `verify-akan-publish-packages` refuses a package
3200
+ whose pack list still contains it.
3201
+
3202
+ - 5f53462: `pathGet` reads every path `pathSet` can write
3203
+
3204
+ The two halves of the dotted-path vocabulary had drifted apart, and only the reading half was short. `pathSet`
3205
+ parsed segments with `/[^.[\]]+/g` and read containers through a `Map` branch; `pathGet` did `path.split(".")` and
3206
+ a bare property access. So `cutFrames[2].content` — the bracket spelling a form field hands `writeOn<Model>` — was
3207
+ three segments to a write and one to a read, and a `field(Map, …)` entry could be written and never read back. The
3208
+ failure is silent in both cases: `pathGet` answers with its fallback, which is `null`.
3209
+
3210
+ Both now share one definition, `toPathSegments`, so a path that writes and a path that reads cannot disagree about
3211
+ what its segments are, and `pathGet` reads a `Map` entry the way `pathSet` writes one. A caller that named its own
3212
+ separator keeps the plain split it has always had — under any separator but `.`, a `[0]` is part of a key rather
3213
+ than an index.
3214
+
3215
+ The argument order is left alone and remains the trap it was: `pathGet(path, obj)` against `pathSet(obj, path,
3216
+ value)`. Flipping it would be caught by the typechecker rather than silently, and there are only two callers — but
3217
+ `pathGetLoose` takes the same `(path, obj)` order as `pathGet`, so flipping one of the pair would replace a trap
3218
+ between get and set with a trap between get and get. The reference documentation's own example had the arguments
3219
+ backwards and is fixed here, which is the evidence that this one is worth deciding rather than leaving implicit.
3220
+
3221
+ - c45ac6c: perf(build): precompress the CSS bundle, and serve brotli sidecars ahead of gzip
3222
+
3223
+ The compiled stylesheet went out uncompressed in production. `precompressArtifacts` only walked
3224
+ `.akan/artifact/client`, so every JS chunk got a `.gz` sidecar while the CSS — written to
3225
+ `.akan/artifact/styles` — got none, and `#fileResponse` fell through to streaming the raw file. Nothing was
3226
+ wrong on the serving side: `#isCompressible` already accepts `text/css` and the sidecar lookup already ran.
3227
+ The file simply was not there. On one deployed app that was 319KB on the wire per cold load, against 45KB
3228
+ gzipped. The CSS is and always was minified — Lightning CSS runs in `prepareCssAsset` — so this is purely the
3229
+ transfer encoding.
3230
+
3231
+ Sidecars are now written for `styles/` too, and a `.br` sidecar is written alongside every `.gz`.
3232
+
3233
+ Brotli is tried first and gzip remains the fallback, because browsers only advertise `br` on secure origins.
3234
+ Quality is 11 for CSS and 9 for everything else: there is one CSS asset per basePath and it is the largest
3235
+ single file an app ships, worth ~20% over gzip for ~0.2s, while spending 11 on several hundred JS chunks costs
3236
+ ~12s of build time for a few hundred KB. Measured on `apps/akan`: 371 files, 14.5MB raw, 4.2MB gzip, 3.9MB
3237
+ brotli, and the CSS bundle 103KB raw → 16.2KB gzip → 13.0KB brotli. The compress phase went from ~0.3s to
3238
+ ~0.64s. Gzip sidecars moved to level 9, which is free at build time and was previously left at the default.
3239
+
3240
+ Encoding negotiation moved out of `WebRouter` and `AkanApp` into `resolveEncodedSidecar`. Both classes carried
3241
+ their own copy of the gzip lookup plus `#acceptsGzip` / `#isCompressible`, and a gateway that disagrees with
3242
+ its child about which encoding a request accepts serves a body the caller cannot decode. The shared version
3243
+ also honours `q=0` as the refusal it is, and no longer treats a token merely starting with `br` as brotli.
3244
+
3245
+ **Anything caching in front of the app must pass `Vary: Accept-Encoding` through.** The header was already
3246
+ set, but it only mattered when one encoding was on offer; with two, a proxy that drops or ignores it will
3247
+ hand a brotli body to a client that asked for gzip. Artifacts now carry both sidecars, so a build's static
3248
+ output grows by roughly the brotli total (3.9MB for `apps/akan`).
3249
+
3250
+ - f882bbc: Keep a projected field at its declared type instead of guessing it from the value.
3251
+
3252
+ Reading with a `select` — which is also every read of a model that carries a `field.secret`, since those force the
3253
+ projection path — decoded each column by looking at the string it came back as: a value starting with `{` or `[`
3254
+ was `JSON.parse`d, everything else passed through. That is a guess about the field's type made from its data, so
3255
+ the same `field(String)` answered as a string most of the time and as an object whenever a caller happened to
3256
+ store JSON in it. `genImage.prompt` is `field.secret(String)`, so an agent-written prompt that was valid JSON
3257
+ came back as an object and went out to the provider as one.
3258
+
3259
+ The cause is `json_extract`, which unwraps a JSON scalar into a SQL value and so erases the difference between
3260
+ the string `'{"a":1}'` and the object it spells — and returns `0`/`1` for a boolean, which the same path handed
3261
+ back as a number. Projection now selects with `->`, whose result is the value's JSON _text_, and parses that: the
3262
+ type comes from what was stored rather than from what it looks like. Postgres already returned typed jsonb and is
3263
+ left alone; only the heuristic that ran after it is gone.
3264
+
3265
+ The non-projection read path was never affected — it decodes against `FIELD_META` — so this only changes reads
3266
+ that named a `select` or touched a model with a hidden or secret field.
3267
+
3268
+ - 2d8de1c: fix(ui): `Refresh` handles pull-to-refresh itself, and `react-simple-pull-to-refresh` is no longer a peer
3269
+
3270
+ `Refresh` loaded `react-simple-pull-to-refresh` through a dynamic import, which the bundler resolves at build time,
3271
+ so the optional peer was in fact required: a workspace that had not installed it failed its CSR build with
3272
+ `Could not resolve: "react-simple-pull-to-refresh"`. `Refresh` now tracks the touch gesture itself — same props,
3273
+ same spinner, refresh past 67px of pull, capped at 95px — and the package is gone from `akanjs`'s peer
3274
+ dependencies. An app that installed it only for `Refresh` can remove it.
3275
+
3276
+ - 2d8de1c: fix(signal): a `query` endpoint that declares a `.body()` argument is named in a warning at declaration
3277
+
3278
+ `fetch` sends a query as `GET` with its `.param` and `.search` arguments in the URL and no body, so a `.body()`
3279
+ argument on a query never arrives over HTTP — a required one fails every call, an optional one is always `null` —
3280
+ while an MCP call does deliver it. Behaviour is unchanged; `endpoint()` now logs one warning per such argument naming
3281
+ the endpoint and the argument, and pointing at `.search()` or a mutation.
3282
+
3283
+ - f882bbc: feat: `RecentTime` accepts a `relative` prop for customizing relative labels
3284
+
3285
+ Default `fromNow` is unchanged (`하루 전`). `"auto"` / `"always"` switch to `Intl.RelativeTimeFormat` (`어제` vs `1일 전`), and a function replaces the relative label entirely — `breakUnit` still decides when the absolute date takes over.
3286
+
3287
+ - ccf2ae2: fix(dev): a page loaded while a save's pages build lands no longer renders without the client components it names
3288
+
3289
+ - `akan start` drops a route's client build when a save invalidates it mid-build. The request that was waiting for
3290
+ that build rendered anyway, from a manifest missing the rows the build would have added, so the RSC payload carried
3291
+ an error row and the page came up blank. The route now builds again at the new generation before it renders.
3292
+
3293
+ - 2d8de1c: fix(server): run a route's head once per render, and not at all for a cached replay
3294
+
3295
+ The RSC worker resolved every matched route's head before deciding how to render, although only a partial-commit
3296
+ patch decision ever reads it. A full render therefore ran the head twice, and a replay from the RSC result cache
3297
+ ran it once, backend queries included. The head now runs for that decision only when partial commit is on, the
3298
+ navigation is a patch candidate and the page declares `rscPatchHeadSafe`; otherwise it runs once, inside the
3299
+ render. A replayed cached page runs no head at all, the same as its body: a head that would now throw `notFound()`
3300
+ or `redirect()` no longer pre-empts a cached replay until the entry expires or is invalidated.
3301
+
3302
+ - 5f53462: The API and websocket prefixes are configurable, and the browser follows them.
3303
+
3304
+ - `new AkanApp({ prefix, websocketPrefix })` in `main.ts` moves the replica's route table, the gateway's websocket
3305
+ upgrade, `robots.txt`, the locale redirect's API bypass and the OpenAPI `servers` URL — and, through the SSR
3306
+ bootstrap script, the `fetchClient` in every tab the server renders. `AkanApp` also accepts its options as the
3307
+ sole argument now, so the server path no longer has to be spelled out to pass one.
3308
+ - `api: { prefix, websocketPrefix }` in `akan.config.ts` is the build's answer, for the bundles a server never
3309
+ gets to correct: a prebuilt CSR shell and a Capacitor bundle carry it inlined, and the generated Dockerfile
3310
+ writes it as the image default.
3311
+ - Resolution order is `globalThis.__AKAN_PREFIX__`, then `AKAN_API_PREFIX` / `AKAN_WS_PREFIX`, then
3312
+ `AKAN_PUBLIC_API_PREFIX` / `AKAN_PUBLIC_WS_PREFIX`, then `/api` and `/ws`. `getApiPrefix()` / `getWsPrefix()`
3313
+ from `akanjs/base` are the readers; `getEnv()` gains `apiPrefix` and `wsPrefix` beside them.
3314
+ - A prefix is a path segment: a blank value or a bare `/` is refused, and so is one whose first segment is a
3315
+ declared basePath, because either would silently trade the API's routes for the page routes or the reverse.
3316
+ `setPrefix` / `setWebsocketPrefix` now throw after `init()`, where the route table is built, instead of being
3317
+ quietly ignored.
3318
+ - `BlobStorage` derives its default URL prefix from the same value. Blob URLs written before a move stay pointing
3319
+ where they were written — they live on the rows that reference them.
3320
+
3321
+ - 5f53462: fix: runtime defects the reference audit turned up
3322
+
3323
+ - `imageOptimizer` re-encodes only JPEG, PNG, WebP and AVIF; a static GIF (or TIFF) that no accepted format beats is
3324
+ served as its own bytes instead of JPEG bytes labelled `image/gif`.
3325
+ - `DataList.sort()` sorts a copy, so the list it was called on keeps a consistent id lookup.
3326
+ - `fetch.view<Model>` exists whenever the model's `get` does, and `edit<Model>` when a create/update/remove endpoint
3327
+ does too; a read-only model used to have neither, and one without `get` had a `view` that always threw.
3328
+ `merge<Model>` still follows `update` alone.
3329
+ - Path parameters are `encodeURIComponent`-ed, so a `/` or `?` in a value no longer reroutes the request.
3330
+ - `ConstantRegistry.serialize` / `deserialize` handle a `Map` of a declared value type instead of throwing.
3331
+ - `BlobStorage` takes `appName` from the deployment env; the server env it was reading has none, so local blobs
3332
+ landed under `local/undefined/`.
3333
+ - UI: `Field.Switch` no longer marks itself optional; `Field.TextList` checks each entry against
3334
+ `minTextlength` / `maxTextlength` and caps the list at `maxlength`; `CsrLink` forwards its props and the caller's
3335
+ `onClick`; `CsrImage` / `Image` pass `alt` through; `useBottomUpTrans` measures height; the safe-area colour falls
3336
+ back to the default page state; `DataColumn.responsive` shows the column from `md` up.
3337
+ - webkit: `useFetch` follows the promise it is handed now rather than only the first one — pass a stable promise, or
3338
+ use `useFetchFn` — and `useCamera` no longer leaves an unhandled rejection in a browser without Capacitor.
3339
+ - `Constant.Doc` and its diagram are translated.
3340
+
3341
+ - 2d8de1c: fix(server): request, render and live-sync fixes from the server bug sweep
3342
+
3343
+ - **A multi-hop `x-forwarded-host` / `x-forwarded-proto` is read by its first value** wherever the server builds a
3344
+ public URL — the host→basePath map, the locale redirect and the rendered alternates. `pub, lb:8080` used to be
3345
+ taken whole, so the map never matched and redirects fell back to the internal origin.
3346
+ - **A client-side navigation renders with the locale and public path a page load of the same URL gets**, so on a
3347
+ sub-route host `usePage().path` no longer reads the internal basePath after a navigation.
3348
+ - **Gateway-served immutable assets carry `X-Content-Type-Options: nosniff` and `Referrer-Policy`**, like every
3349
+ response the web router sends. The gateway answers `/_akan/client|styles|fonts/*` itself under `akan start` and
3350
+ with two or more replicas.
3351
+ - **A respawned RSC worker whose init fails is replaced** instead of leaving every render queued behind it until
3352
+ the process restarts.
3353
+ - **A multi-field query loader keys each document unambiguously.** Values that concatenated alike (`x` + `yz` and
3354
+ `xy` + `z`) shared a key, and both callers in the batch got the later document.
3355
+ - **A live-sync room counts one join per socket.** A stray unsubscribe no longer drops a room other sockets are
3356
+ still in, and a socket that subscribed to the same room twice no longer leaves it behind when it closes.
3357
+ - **`/mcp` reads the bearer scheme case-insensitively and past repeated spaces** (RFC 7235, RFC 6750), so
3358
+ `bearer <token>` is judged like `Bearer <token>` instead of passing as no credential.
3359
+
3360
+ - 2d8de1c: fix(client): `router.push` / `replace` on the server warn that they do nothing, and name `router.redirect()`
3361
+
3362
+ Called during a server render, both only wrote an `info` line and returned — no navigation, no redirect — so a page
3363
+ that meant to send the visitor elsewhere rendered in place with nothing louder than a log line at the default level.
3364
+ They now log a warning that names `router.redirect()`, the call that answers a server render with a redirect. They
3365
+ still do not throw, so a page that renders today keeps rendering.
3366
+
3367
+ - 2d8de1c: fix(server): a backend finishes its shutdown and delivers its last log lines before it exits
3368
+
3369
+ - **The last log batch reaches the parent before a backend exits.** A message sent over IPC right before
3370
+ `process.exit` is lost from 8 KB on, so in `AKAN_LOG_FORMAT=ndjson` the shutdown lines and any final error stack
3371
+ never reached stdout. A backend now waits, up to one second, until that batch is delivered.
3372
+ - **A second `stop()` waits for the shutdown already under way.** It used to return at once, so its caller exited
3373
+ while the first shutdown was still closing services and the log transport — a second SIGINT, or every Ctrl-C
3374
+ under a gateway, where the terminal's signal and the gateway's `shutdown` message arrive together.
3375
+ - The ops snapshot route's 400 now states the id pattern it actually enforces (the first character must be a
3376
+ letter or digit).
3377
+ - An `AKAN_SUB_ROUTE_HOSTS` basePath that is ignored is warned about once per process instead of twice.
3378
+ - The builder-ready log line no longer prints `buildId=undefined`.
3379
+
3380
+ - f882bbc: Declare `client/sharedContext.ts` a client module, fixing a boot failure in 3.0.0-alpha.58.
3381
+
3382
+ Under the `react-server` exports condition `react` resolves to `react.react-server.js`, which exports no
3383
+ `createContext` and no stateful hook. The new helper was the one module in `client/` and `ui/` calling
3384
+ `createContext` without `"use client"`, so a server component importing anything from `akanjs/client` pulled it
3385
+ into the react-server graph and the app died at boot with `Export named 'createContext' not found`. Neither
3386
+ typecheck nor build reports it: both resolve `react` the ordinary way.
3387
+
3388
+ A test now fails if any module under `client/` or `ui/` imports a react binding the react-server condition does
3389
+ not provide — `createContext`, `useState`, `useEffect`, `useLayoutEffect`, `useContext`, `useRef`, `useReducer`,
3390
+ `useSyncExternalStore`, `useImperativeHandle` — without declaring the directive. `useMemo`, `useCallback`,
3391
+ `useId`, `createElement` and `Fragment` are provided there and stay unflagged, so a server component keeps using
3392
+ them.
3393
+
3394
+ - f882bbc: Render a `Map` field in the API explorer instead of crashing the page.
3395
+
3396
+ A Map field's `modelRef` is the `Map` constructor itself — it belongs to no registry, and the value type lives in
3397
+ `of`. `Signal.Object.Detail` handed that constructor to `ConstantRegistry.getModelName`, which threw
3398
+ `No ref name for modelRef: function Map()`, so every model carrying one took down `<Signal.Doc.Zone />` on render.
3399
+ It now labels the column `Map` and leaves the existing `⇒ <value type>` beside it.
3400
+
3401
+ The request and response examples had the same blind spot one layer down: they walked `Map[FIELD_META]`, which is
3402
+ `undefined`, so an endpoint whose body or return carried a Map threw before the sample JSON was built. A Map now
3403
+ serializes into the example as the string-keyed object it is on the wire — `{ "key": <value example> }`.
3404
+
3405
+ - 2d8de1c: fix(signal): mark a slice's `.body()` deprecated — `fetch` never sends its argument
3406
+
3407
+ A slice is served as GET queries (its list and insight reads), and a GET carries no body: `fetch.*` sends only a
3408
+ slice's `.param` and `.search` arguments. A required `.body` argument therefore failed every call with a
3409
+ missing-value error, and an optional one always arrived as `null`. `.body()` still declares the argument exactly as
3410
+ before, so nothing changes at runtime; the editor now shows it as deprecated. Declare the argument with `.search`
3411
+ (or `.param`) instead.
3412
+
3413
+ - 5f53462: fix: `Load.Units` rehydrates when a route change swaps its `init` for one with different query args
3414
+
3415
+ A slice's store is one global bucket keyed by the slice name, so `ticketListInProject` is shared by every project.
3416
+ `Load.Units` latched hydration on a mount-scoped `useRef(false)` with `[]` effect deps, so the first `init` it saw
3417
+ was the only one it ever read. Navigating between two routes that render the same slice with different args — a
3418
+ project switch, an org switch, any `[id]` change under a shared tab layout — reuses the mounted instance, and the
3419
+ latch made it keep rendering the previous route's rows: the page fetched the right data on the server and threw it
3420
+ away on the client.
3421
+
3422
+ Hydration identity is now the init payload's `queryArgs`, matching what `Load.View` already does with the model
3423
+ id: `loaded` is true only while the hydrated args still equal the ones `init` carries, and the memo and both
3424
+ effects key off the same signature. Same args arriving again — a `router.refresh()`, a re-render — still hydrate
3425
+ once, so client-side page, sort, and filter state is not clobbered.
3426
+
3427
+ - 5f53462: A solo replica logs its boot at `info`, so a production container that runs the one traffic replica prints a line
3428
+ instead of leaving stdout empty until something goes wrong.
3429
+
3430
+ `b42b7246` demoted that line to `debug` while trimming dev boot output. In a gateway deployment it costs nothing —
3431
+ `AkanApp gateway is running on port …` is `info`, and `akan start` never takes the solo path — but
3432
+ `AKAN_REPLICA=0,0,1`, which is what the chart deploys, has no gateway, and every other line a solo boot passes
3433
+ through is `verbose`. With the image's `AKAN_LOG_TO_FILE=0` and canonical request lines off by default, a healthy
3434
+ pod logged nothing at all: `kubectl logs` on a running container came back empty, and a crash looked the same as a
3435
+ quiet boot.
3436
+
3437
+ - 5f53462: `Loading.Spin` takes the color and the size the caller gives it, instead of pinning both to the icon
3438
+
3439
+ The spinner hard-coded `text-primary/70` and `text-xl` **on the icon**, while `className` landed on the wrapper
3440
+ around it. So every `text-*` and every `size-*` a caller passed was a silent no-op — `cn` merged it into a class
3441
+ list the icon never read — and the worst case was a filled surface: a `<Badge variant="info">` renders
3442
+ `text-info-foreground`, the spinner inside it stayed `text-primary/70`, and green-on-cyan at L 70% against L 74%
3443
+ is a 1.10:1 contrast that leaves the badge's text legible and the spinner invisible.
3444
+
3445
+ Both now sit on the wrapper. The icon is drawn at `1em` in `currentColor`, so color and size cascade into it, and
3446
+ `className` — merged last — wins over either. `tone="current"` names no color at all, which is what a filled
3447
+ surface needs: the spinner inherits whatever foreground the badge or the button already set. `tone` defaults to
3448
+ `"primary"`, so nothing that exists renders differently, and `"muted"` is there for a secondary surface.
3449
+
3450
+ `size` also takes a number now, drawn as that many pixels, because the named steps were the only vocabulary and a
3451
+ caller reaching past them wrote `size-[50px]` — a 50px empty box around a 20px icon. A custom `indicator` is left
3452
+ alone by `tone` exactly as before: it carries its own color, and the rotation is the wrapper's.
3453
+
3454
+ - ccf2ae2: feat(dev): SSR pages load their client code from a dev module registry
3455
+
3456
+ - Under `akan start`, an SSR page's `"use client"` code now comes from a module registry under
3457
+ `.akan/artifact/ssr-dev`, the same machinery the dev CSR registry uses, instead of the route chunks. React and the
3458
+ akanjs vendor facets stay the import map's, so the page keeps one React and one store registry. The route chunks and
3459
+ the `client-refresh` that re-imported all of them are gone from dev; `akan build` is unchanged.
3460
+ - A save patches the changed module in place with React Fast Refresh: state stays, and on minimal a component edit
3461
+ shows in about 110ms instead of 900ms. Undoing an edit now shows too; with chunks the browser kept the module it had
3462
+ already imported for that URL. A client module the RSC payload names that also exports constants re-runs in place.
3463
+ - A save that changes nothing the server renders (a `"use client"` module that keeps its export names, or a module only
3464
+ client code imports) keeps the page's build id and refetches no RSC payload. One that does — a component both sides
3465
+ render, a constant — holds its client patch until that save's pages build lands, then sends the patch and the RSC
3466
+ refresh together, so the page never runs new client code against old server props. A constant edit that reloads
3467
+ waits for that build too, and a backend restarted by a server-only save renders from the latest pages build.
3468
+ - The registry builds once in a build worker after the dev server boots, beside the builder's slow lane, and each route
3469
+ build adds the entries its page names that the registry lacks; a page served before the registry exists waits for
3470
+ it in the browser, in short holds. Route builds no longer bundle browser chunks in dev, which took about 240MB off
3471
+ the builder's peak on apps/akan.
3472
+ - Most saves are patched in the resident builder: a new file, a new import from a package something already resolved,
3473
+ a moved or renamed file (a case-only rename, `Foo.tsx` to `Foo/index.tsx`, a file created beside a folder an import
3474
+ resolved to), a deleted module brought back. A whole build in a build worker is left for a new npm package, a signal
3475
+ save (its metadata is inlined into the client runtime; a dictionary save does not rebuild the SSR registry, which
3476
+ inlines none), and a registry whose last whole build was cut short. A save that does not compile — a typo in an
3477
+ import included — is reported by the builder at once, and the save that fixes it patches in about 120ms.
3478
+ - A failed build keeps the registry the tabs and the next save read. Its error stays on the overlay, is shown to a tab
3479
+ opened while it stands, and clears with the save that fixes it — for a route that failed on a shared module too,
3480
+ which is built again once a newer build goes green. A broken file no page imports any more stops failing saves.
3481
+ - A client module whose top level threw, or a store that throws at startup, recovers with the save that fixes it, and
3482
+ the dev server's error page carries the HMR client, so it reloads once the fix lands. A pages bundle that builds but
3483
+ throws while it loads shows its error while the RSC worker keeps serving the bundle it had.
3484
+ - A tab that reconnects after a backend restart loads the patches it missed and refetches its payload instead of
3485
+ reloading; one that reconnects to a new dev session (a new `akan start`, a config restart) reloads onto its registry.
3486
+ A tab that booted beside the vendor file of the build before loads the newer one, so a new npm import in a leaf
3487
+ component works without a manual refresh.
3488
+ - A build worker exits with the builder that spawned it, and a boot build whose worker was killed is retried after 10
3489
+ seconds. On Windows, registry writes retry a rename refused while a reader holds the file, and a build worker hashes
3490
+ the same bundle config as the builder, so saves are not all handed to whole builds.
3491
+
3492
+ - 2d8de1c: fix(server): a client-side navigation on a sub-route host stays inside that host's basePath
3493
+
3494
+ On a host mapped to a basePath (`subRoutes` or `AKAN_SUB_ROUTE_HOSTS`), a page load rewrites every path into that
3495
+ basePath, so a path only another basePath serves is a 404 there. A client-side navigation (`<Link>`,
3496
+ `router.push`) also tried the other basePaths and rendered their pages under the mapped host's domain: on the
3497
+ `soft` host, `/en/admin` and `/en/office/admin` both showed `office`'s admin page. The navigation now resolves its
3498
+ target exactly as a page load on that host does, so the two answer the same page, or both a 404. A host with no
3499
+ basePath of its own (a debug host, `akan start` on localhost) still finds the basePath whose route matches.
3500
+
3501
+ - 69f7178: fix(service): let `onInit` and `onDestroy` be declared sync
3502
+
3503
+ `Adaptor` and `Service` both declared the pair as `onInit(): Promise<void>`, so a hook with nothing to await was a
3504
+ type error even though every call site already awaited it:
3505
+
3506
+ ```
3507
+ Property 'onInit' in type 'SyncAdaptor' is not assignable to the same property in base type '… & Adaptor'.
3508
+ ```
3509
+
3510
+ Both hooks — and the interceptor's pair, which is the same shape — now return `Promise<void> | void`, the same
3511
+ spelling `_preRemove` and the DI's own `onDestroy` probe already use. `override async onInit()` is unchanged.
3512
+
3513
+ ```ts
3514
+ export class Cursor extends adapt("cursor" as const, () => ({})) {
3515
+ override onInit() {
3516
+ this.watcher = watch(this.root);
3517
+ }
3518
+ override onDestroy() {
3519
+ this.watcher?.close();
3520
+ }
3521
+ }
3522
+ ```
3523
+
3524
+ The default no-op bodies stopped being `async` as part of the same change: `serve()`'s implementation returns the
3525
+ raw class, and TypeScript checks each overload's return type against it in one direction or the other, so widening
3526
+ the interface while the base stayed `async` left neither direction assignable and broke `serve` itself with TS2394.
3527
+ The one observable consequence is that an un-overridden `onInit()` returns `undefined` rather than a resolved
3528
+ promise — `await` reads both the same, but `.then()` on it does not.
3529
+
3530
+ `_libsOnInit` / `_libsOnDestroy` now wrap each hook call. A sync hook that throws used to escape the `.map` before
3531
+ `Promise.all` was ever reached, stranding the promises the async hooks beside it had already started; the throw
3532
+ now arrives as a rejection with every sibling still attached.
3533
+
3534
+ - 5f53462: feat: the agent's pointer lives for the turn, and waits out the model's own thinking
3535
+
3536
+ The pointer's life was measured in the gap between **calls** — 1.4 seconds of quiet and it faded. But a model's
3537
+ calls arrive with its own writing between them, which takes several seconds, so a turn of four calls was four
3538
+ pointers: each one appeared, pressed, and vanished before the next arrived. The unit was wrong.
3539
+
3540
+ `AgentSession` now reports the boundary that matters. `onTurn(running)` fires once when a turn starts and once
3541
+ when it settles, and only the conversation loop reports there: `compact` runs under the same internal flag and
3542
+ drives nothing on screen, so a host drawing the agent at work would otherwise draw it for a summary nobody asked
3543
+ to watch. Between the calls of one turn the pointer stays where it last landed and turns into a spinner, because a
3544
+ pointer sitting perfectly still for four seconds reads as stuck rather than as waiting.
3545
+
3546
+ It still appears only at the first control it presses, and **a turn that drove no control draws no pointer at
3547
+ all** — parking one in a corner for a turn that only answered a question would be the screen claiming something
3548
+ that did not happen.
3549
+
3550
+ While a reveal scrolls the page to the next control, the pointer holds still the way a person's does and carries a
3551
+ chevron pointing the way the view is travelling — stillness over a sliding page otherwise reads as a pointer that
3552
+ has come loose rather than as the one doing the scrolling. The scroll budget is now spent per turn as well, instead
3553
+ of per quiet second, which is what it was always guessing at.
3554
+
3555
+ **`navigate` draws again, but only on an element.** The link a navigation is going to, when exactly one visible
3556
+ link goes there, is pressed before the route moves — which needs the call to wait, so `ToolRunner` now awaits the
3557
+ `start` activity and `AgentSession.onActivity` may answer a promise. `AgentVisual` returns one for that call alone
3558
+ and caps it at 600ms; everything else still draws while the call is already running. An off-screen link is left
3559
+ alone, because scrolling to a link and then leaving the page it is on is two motions for one act. **Link presence
3560
+ decides what is drawn, never what is allowed**: the agent may still navigate anywhere the user could type, and a
3561
+ zone that must stay on one screen drops `navigate` through `builtins` as before.
3562
+
3563
+ `Router.routeOf(pathname)` is what compares the two addresses — the locale and base-path segments live on the
3564
+ `<a href>` and never on the tool argument. It is `getPath` without the browser guard, which `getPath` only needs
3565
+ for its own default argument.
3566
+
3567
+ **The pointer clears the control before it waits.** A spinner parked on the button it just pressed reads as one
3568
+ stuck to it, and it covers the very change the press caused — a person clicks and takes the hand away. It drifts
3569
+ just past the control's box, down and to the right unless the viewport edge is there, and spins from that spot.
3570
+
3571
+ **A control the screen is not actually showing is not pointed at, and the pointer goes rather than travelling to
3572
+ it.** `checkVisibility` answers about an element alone, so one under a modal's backdrop, inside a drawer that has
3573
+ slid off, or faded to nothing all pass it while being invisible to the user — and a pointer sent there lands on a
3574
+ blank patch of overlay, which reads as the effect being broken rather than as the agent acting. The test is
3575
+ `elementFromPoint` at the control's own centre, taken after the reveal has settled, with an ancestor counting as a
3576
+ hit so a `<label>` wrapping its input is not mistaken for an occlusion.
3577
+
3578
+ **`agentAttrs(handler, key)` tells namesakes apart.** One tool serves a whole tab strip or a whole list, so every
3579
+ control carrying it was interchangeable in the DOM and the no-guessing rule turned that into drawing nothing.
3580
+ A key — in the same vocabulary the call's argument uses — lets the pointer pick the one the call named; short of
3581
+ exactly one answer nothing is still drawn. `Tab.Menu` now passes its own `menu` key, so switching a tab is drawn
3582
+ on the menu that was switched to.
3583
+
3584
+ `reveal` and `cursor` are now independent, as their names always claimed: `visual={{ reveal: false }}` keeps the
3585
+ pointer and drops the ring, where before it dropped both.
3586
+
3587
+ - 2d8de1c: fix(tunnel): a shared app keeps answering after its websockets close
3588
+
3589
+ A finished websocket stream closed its data socket, but the tunnel agent kept counting that socket as idle, so it
3590
+ never opened a replacement. Every page reload closes its HMR socket, so an `akan start --share` tunnel with the
3591
+ default four idle sockets stopped answering after about five reloads, until the control link reconnected. A
3592
+ finished stream now retires its socket and the pool refills it.
3593
+
3594
+ A raw TCP stream also ended in a stack overflow (the stream and its socket closed each other in a loop), and it
3595
+ forwarded a fast origin's output without waiting for the tunnel socket to drain. Neither path is opened by the
3596
+ current wire protocol; both are fixed ahead of it.
3597
+
3598
+ - 2d8de1c: fix(tunnel): a websocket stream the gateway resets gives its data socket back
3599
+
3600
+ When the gateway reset a tunnelled websocket, the agent closed the local websocket but left the data socket that
3601
+ carried it open. The wire contract never returns a raw stream's socket to the pool, so the gateway could not use it
3602
+ again, while the agent counted it as idle and opened no replacement: every reset shrank the working pool by one.
3603
+ The agent now closes that data socket, as it already did when a websocket or TCP stream ends on its own side or the
3604
+ gateway resets a TCP stream, and opens a fresh one in its place.
3605
+
3606
+ - 2d8de1c: fix(tunnel): a tunnelled TCP stream half-closes instead of hanging up
3607
+
3608
+ The wire contract ends each direction of a stream on its own, but the agent's raw TCP stream treated the
3609
+ gateway's `end` as the end of everything. It closed the connection to the local port outright, so whatever the
3610
+ origin answered after the caller's end of input was lost, and bytes the caller sent after the origin finished its
3611
+ own side were dropped too. The agent now shuts down only the direction that ended: the origin sees end of input and
3612
+ can still answer, and the caller can keep sending after the origin's end. The data socket goes back once both
3613
+ directions are done or the stream is reset. The local connection is dialled through `node:net`, because
3614
+ `Bun.connect` stops reading once its write side is shut down (Bun 1.4). Wire version 1 opens no TCP stream yet, so
3615
+ no running tunnel behaves differently today.
3616
+
3617
+ - 2d8de1c: fix(tunnel): a tunnelled websocket stream's `run()` finishes however the stream ends
3618
+
3619
+ `TunnelWebsocketStream.run()` (`akanjs/server/tunnel`) resolved only when the local websocket closed or failed on
3620
+ its own. When the stream was reset instead — by the gateway, by the gateway dropping the data socket under it, or by
3621
+ the agent stopping — the promise never settled, unlike the http and tcp streams. It now resolves once on every path,
3622
+ and a reset that arrives after the stream has already ended releases the data socket no second time. The agent
3623
+ itself never awaited it, so a tunnel behaves the same.
3624
+
3625
+ - 5f53462: fix: every store write marks the slice payloads stale, and `Load.View` refetches a replayed one
3626
+
3627
+ A write patches the lists and the model the store holds, but the RSC payload each of them was hydrated from still
3628
+ carries the pre-write rows — and `rscCache` replays that payload verbatim when the user navigates back to a route
3629
+ they already visited. So changing a ticket's status in one project, switching projects, and coming back showed the
3630
+ status the page was first rendered with.
3631
+
3632
+ `staleAtOfOtherSlices` was the only guard against this and it was half-wired: it ran on `create<Model>` /
3633
+ `create<Model>InForm` only, and it excluded the slice that issued the write. `update<Model>`,
3634
+ `update<Model>InForm`, `merge<Model>`, `remove<Model>` and `set<Model>` — the last of which is where every custom
3635
+ mutation endpoint commits its result — stamped nothing at all.
3636
+
3637
+ It is now `staleAtOfSlices`, stamped by all seven writes across every slice including the issuing one. The
3638
+ exclusion bought nothing: the stamp starts no fetch, because `Load.Units` only re-checks staleness when a list
3639
+ re-hydrates, and the slice that took the optimistic patch is exactly as far behind in its payload as its siblings.
3640
+
3641
+ `Load.View` had the same hole with no guard at all — view a model, edit it, view another, come back — and now
3642
+ refetches through `view<Model>` when the payload it is re-hydrating from predates the last local write. It reads
3643
+ `<refName>StaleAt`, the root slice's stamp, and does nothing on a model whose signal declares no slice.
3644
+
3645
+ Both checks compare a server-generated `initAt`/`viewAt` against a client-generated `staleAt`, so a badly skewed
3646
+ client clock costs an extra refetch per navigation (clock ahead) or misses one (clock behind). That trade is
3647
+ unchanged from the create path this generalizes.
3648
+
3649
+ - f882bbc: Rename a zone's scope-prefixed tools onto what a provider's function-calling wire accepts.
3650
+
3651
+ A zone publishes `<id>.<name>`, which is legal for MCP but rejected by every OpenAI-compatible and Anthropic
3652
+ function schema — those allow `[A-Za-z0-9_-]` only, up to 64 characters. The name went to the provider verbatim.
3653
+ A validating provider answers 400; DeepSeek did not, and what happened instead was harder to find: the model
3654
+ normalized the illegal name itself, called the bare tool, and the browser answered `Unknown tool` for a tool that
3655
+ was published all along, spending a turn.
3656
+
3657
+ `AgentService.runTurn` now renames every tool name in the request — the published tools and the calls the
3658
+ transcript still carries — and reads the answer back under the name the surface registered. A request whose names
3659
+ already fit is handed on as the same object, so the root agent's turn is unchanged. `Agent.Context`'s Assemble
3660
+ now lists the published tool names, which is where a prefix mismatch is visible in one glance.
3661
+
3
3662
  ## 2.4.2
4
3663
 
5
3664
  ### Minor Changes