akanjs 3.0.0 → 3.0.2-rc.0

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