@abide/abide 0.32.2 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +85 -63
  3. package/bin/abide.ts +6 -2
  4. package/package.json +6 -2
  5. package/src/devEntry.ts +12 -1
  6. package/src/lib/bundle/exitWithParent.ts +7 -7
  7. package/src/lib/server/runtime/buildCacheSnapshot.ts +5 -4
  8. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  9. package/src/lib/shared/cache.ts +43 -29
  10. package/src/lib/shared/types/CacheEntry.ts +12 -12
  11. package/src/lib/shared/types/CacheOptions.ts +17 -13
  12. package/src/lib/ui/README.md +1 -1
  13. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +4 -1
  14. package/src/lib/ui/compile/componentWrapperTag.ts +1 -1
  15. package/src/lib/ui/compile/generateBuild.ts +265 -121
  16. package/src/lib/ui/compile/generateSSR.ts +78 -37
  17. package/src/lib/ui/compile/parseTemplate.ts +34 -0
  18. package/src/lib/ui/compile/skeletonable.ts +80 -0
  19. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +6 -0
  20. package/src/lib/ui/dom/SVG_NAMESPACE.ts +7 -0
  21. package/src/lib/ui/dom/anchorCursor.ts +24 -0
  22. package/src/lib/ui/dom/appendSnippet.ts +1 -1
  23. package/src/lib/ui/dom/appendTextAt.ts +70 -0
  24. package/src/lib/ui/dom/awaitBlock.ts +27 -7
  25. package/src/lib/ui/dom/cloneStatic.ts +14 -7
  26. package/src/lib/ui/dom/each.ts +44 -25
  27. package/src/lib/ui/dom/eachAsync.ts +6 -2
  28. package/src/lib/ui/dom/effectiveChildNamespace.ts +13 -0
  29. package/src/lib/ui/dom/enterNamespace.ts +20 -0
  30. package/src/lib/ui/dom/fillBefore.ts +20 -3
  31. package/src/lib/ui/dom/foreignWrapperTag.ts +22 -0
  32. package/src/lib/ui/dom/hydrate.ts +1 -1
  33. package/src/lib/ui/dom/inheritedNamespace.ts +19 -0
  34. package/src/lib/ui/dom/mountSlot.ts +32 -0
  35. package/src/lib/ui/dom/openMarker.ts +4 -2
  36. package/src/lib/ui/dom/skeleton.ts +202 -0
  37. package/src/lib/ui/dom/switchBlock.ts +10 -3
  38. package/src/lib/ui/dom/tryBlock.ts +7 -5
  39. package/src/lib/ui/dom/types/SkeletonHoles.ts +8 -0
  40. package/src/lib/ui/dom/when.ts +6 -2
  41. package/src/lib/ui/installHotBridge.ts +8 -2
  42. package/src/lib/ui/runtime/HOLE_ATTRIBUTE.ts +9 -0
  43. package/src/lib/ui/runtime/RENDER.ts +7 -0
  44. package/src/lib/ui/runtime/types/UiProps.ts +3 -4
  45. package/template/src/ui/pages/about/page.abide +4 -6
  46. package/template/src/ui/pages/layout.abide +21 -0
  47. package/template/src/ui/pages/page.abide +5 -8
  48. package/src/lib/ui/compile/partitionSlots.ts +0 -36
  49. package/src/lib/ui/dom/openChild.ts +0 -22
  50. package/template/src/ui/Layout.abide +0 -19
package/AGENTS.md CHANGED
@@ -134,7 +134,7 @@ Resolve only during an in-flight SSR render or RPC handler (throw outside one):
134
134
  ## Isomorphic surface — `abide/shared/*`
135
135
 
136
136
  ### Cache — `@readme cache`
137
- - `abide/shared/cache(fnOrProducer)` → a memoised callable. Key auto-derives (method+url+args for a remote fn; producer-reference+args for a producer). Options `{ ttl?, scope?, global?, invalidate? }`: `ttl` ms-past-resolve (omit = forever, `0` = dedupe-only / the **mutation idiom**); `scope` tags for group invalidation; `global` = process-level store (server); `invalidate: { throttle | debounce }` = stale-while-revalidate refetch policy (GET-only, throws on a write).
137
+ - `abide/shared/cache(fnOrProducer)` → a memoised callable. Key auto-derives (method+url+args for a remote fn; producer-reference+args for a producer). Options `{ ttl?, scope?, global?, swr? }`: `ttl` ms-past-resolve (omit = forever, `0` = dedupe-only / the **mutation idiom**); `scope` tags for group invalidation; `global` = process-level store (server); `swr` = stale-while-revalidate — on invalidate, keep the stale value (reader sees `refreshing()`, not `pending()`) and refetch in the background. `swr: true` refetches immediately; `swr: { throttle | debounce }` adds a coalescing window. GET-only, throws on a write.
138
138
  - `cache.invalidate(selector?, args?)` — end retention early (by fn, fn+args, `{ scope }`, or all).
139
139
  - `cache.patch(selector, updater, args?)` — optimistic local fold over matching entries.
140
140
  - `cache.on(subscribable, (frame, ctx) => …)` — event-driven cache maintenance: run a handler per socket/stream frame; `ctx.invalidate` / `ctx.patch` are coverage-tracked and resync on reconnect. Client-only (no-op on server).
@@ -174,13 +174,13 @@ Same selector grammar as `cache.invalidate`; also accept a `Subscribable`. See C
174
174
  - `abide/ui/router(...)`, `abide/ui/startClient(...)`, `abide/ui/renderToStream(render)` — bootstrap/render runtime (compiler/launcher uses these).
175
175
 
176
176
  ### DOM + render runtime — `@readme plumbing` (compiler-emitted; you don't hand-write these)
177
- `abide/ui/dom/{mount,mountChild,hydrate,text,openChild,appendText,appendSnippet,appendStatic,cloneStatic,attr,on,attach,each,eachAsync,when,awaitBlock,tryBlock,switchBlock,applyResolved}` and `abide/ui/runtime/{nextBlockId,enterRenderPass,exitRenderPass}`. These are what `analyzeComponent → generateBuild/generateSSR` lower a `.abide` file into. Read them only to understand compiler output.
177
+ `abide/ui/dom/{mount,mountChild,hydrate,text,appendText,appendTextAt,appendSnippet,appendStatic,cloneStatic,skeleton,anchorCursor,mountSlot,attr,on,attach,each,eachAsync,when,awaitBlock,tryBlock,switchBlock,applyResolved}` and `abide/ui/runtime/{nextBlockId,enterRenderPass,exitRenderPass}`. These are what `analyzeComponent → generateBuild/generateSSR` lower a `.abide` file into — every bound element builds through the parser-backed `skeleton` (one clone + located holes / `<!--a-->` anchors); there is no imperative element builder. Read them only to understand compiler output.
178
178
  - `abide/ui/remoteProxy`, `abide/ui/socketProxy` — the browser-side implementations the bundler swaps in for `GET(...)` / `socket(...)`.
179
179
 
180
180
  ### `.abide` component format (see `src/lib/ui/README.md`)
181
181
  Valid HTML with `<script>` + native `<template>` control flow + scoped `<style>`.
182
182
  - **Bindings:** `{expr}` text, `name={expr}` attr, `onclick={fn}`, `bind:value={…}` / `bind:checked` / `bind:group`, `attach={fn}` (node-lifetime attachment — the dual of `on`; the `use:`-action / `{@attach}` equivalent, lowered to `ui/dom/attach`).
183
- - **Control flow (native `<template>`):** `if`/`else`, `each={list} as="x" key="x.id"`, `await={p}`/`then`/`catch`, `switch`/`case`/`default`.
183
+ - **Control flow (native `<template>`):** `if` with a nested `else` (the `<template else>` is a CHILD of its `<template if>`, not a sibling), `each={list} as="x" key="x.id"`, `await={p}`/`then`/`catch`, `switch`/`case`/`default`.
184
184
  - **A branch is a lexical scope:** any control-flow branch may host its own nested `<script>` and `<style>`. The nested `<script>` declares branch-local **plain** signals (`state`/`derived`/`linked`/`prop`) — owned by the branch's render scope, re-seeded each mount (not the serializable top-level `doc`) — with the branch's binding in scope (`then`/`catch`'s `as` value, the `each` `as`/`key` row), so it can derive from the awaited/iterated value; its bindings cover the branch subtree and later siblings auto-deref them. The nested `<style>` is scoped to that branch alone (its own `data-a-<hash>`), not the whole component.
185
185
  - **Components:** capitalised tags (`<Layout title=…>`); children fill `<slot/>`; props are reactive (passed as thunks). A component has no directives — every attribute is a prop under its written name (so `onclick=`/`bind:open=`/`attach=` pass through as props, e.g. callbacks, not the DOM-element directives those are on a lowercase tag) and is type-checked against the child's declared props. `prop('name')` reads a typed component prop (the parent-supplied thunk, reactive + read-only); route params come from the `page` proxy (`page.params.name`), not `prop()`.
186
186
  - **Snippets / named slots:** `<template name="x" args={…}>` declares a reusable named builder (the `snippet()` form), rendered like a function — covers named slots / `{@render}`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # abide
2
2
 
3
+ ## 0.33.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`e9fb88f`](https://github.com/briancray/abide/commit/e9fb88f517d9c7eac24044a6b65f473263525a6e) - keep a wedged dev orchestrator from orphaning the server on the port ([`a29800d`](https://github.com/briancray/abide/commit/a29800d76300ff4d1c99a4d27dc790562a4af57b))
8
+
9
+ ## 0.33.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [`8af031e`](https://github.com/briancray/abide/commit/8af031e74ff3f3ab9cef05df75c0bbc22bd370d6) - feat(cache): rename the cache invalidation-policy option from `invalidate` to `swr` (stale-while-revalidate). `swr: true` keeps the entry and refetches in the background on a `cache.invalidate` hit — the stale value stays visible and `refreshing()` reports the in-flight reload — instead of dropping the entry to `pending()`. An optional window coalesces a burst of invalidations: `swr: { throttle: N }` refetches on the leading edge then at most once per N ms, `swr: { debounce: N }` refetches only after N ms of quiet. `cache()` still throws at wrap time on throttle+debounce set together, on `ttl: 0`, and on a non-replayable remote method. Replaces the former `invalidate: { throttle, debounce }` option (same coalescing semantics, clearer name).
14
+
15
+ ### Patch Changes
16
+
17
+ - [`9d4d899`](https://github.com/briancray/abide/commit/9d4d8992499bc7ef6e9cfc963dafc819725adabe) - fix: keyed `each`, `when`, and `switch` no longer crash when a synchronous write rebuilds them mid-hydrate. A page seeding shared reactive state during the hydrate pass (e.g. `breadcrumbs.crumbs = [...]`, or flipping an `if`/`switch` condition) re-ran the block's effect while `RENDER.hydration` was still active, so its fresh build claimed SSR nodes that were never adopted — surfacing as `null is not an object (element.setAttribute)` in `attr`/`openChild`. The reactive rebuild now runs with the global claim cursor cleared (restored after, mirroring `awaitBlock`/`tryBlock`): `each` clears it around its reconcile body, and `when`/`switch` get it for free via `fillBefore`, their single fresh-build path — adopt happens in place, so `fillBefore` is only ever create mode.
18
+
19
+ - [`9d4d899`](https://github.com/briancray/abide/commit/9d4d8992499bc7ef6e9cfc963dafc819725adabe) - fix: SVG and MathML now render correctly on the client. The imperative element builder used `document.createElement`, so a foreign element built on the client (an `<svg>` with a binding, a bound `<path>` inside an svg, foreign content with reactive children, or foreign elements generated by `if`/`each`/`await`) landed in the HTML namespace and rendered as nothing — SSR was unaffected, the divergence appeared only on the client-create path. Three changes close it: a static foreign subtree carrying bindings builds through a new parser-backed `skeleton` (one clone of a `<template>`-parsed skeleton, holes located and wired by index — the browser parser namespaces foreign content for free); the imperative path (`openChild`/`cloneStatic`) now namespaces elements off their parent via `createElementNS`/a foreign parse wrapper; and control-flow blocks (`when`/`switch`/`each`/`eachAsync`/`await`/`try`) carry an ambient foreign-namespace context (`RENDER.namespace`) across the detached fragment they build into, which `skeleton`/`openChild`/`cloneStatic` consult. Hydration was already correct (it adopts server-namespaced nodes).
20
+
21
+ - [`9d4d899`](https://github.com/briancray/abide/commit/9d4d8992499bc7ef6e9cfc963dafc819725adabe) - refactor(ui): unify the client render backend on the parser-backed `skeleton` and delete the imperative `openChild` element builder. Every bound element now builds through one clone-with-located-holes path — element holes by element-only path, and reactive text / control-flow blocks / `<slot>` outlets positioned by `<!--a-->` anchors (`anchorCursor`), so a block or slot can sit anywhere among static siblings (not just in a `[prefix][contiguous blocks][suffix]` shape). Nested `<script>`/snippet children build through the skeleton too. Foreign content (SVG/MathML) keeps its namespace in every case (the browser parser is the sole tree-builder). New plumbing: `abide/ui/dom/anchorCursor`, `abide/ui/dom/mountSlot`. Removed: `abide/ui/dom/openChild`, `abide/ui/dom/cursorAfterElements` (and the internal `childNamespace`). SSR emits the matching `<!--a-->` anchors, so the SSR == client DOM invariant holds. No public `.abide` behaviour change.
22
+
23
+ Also fixes `<template await>` hydration when the block is genuinely pending (no resume value, not warm-sync): it discards the SSR boundary and rebuilds, and now (a) inserts at the node _after_ the discarded boundary rather than the captured insertion ref — which for a skeleton-anchored block was the await's own open boundary, removed by the discard, so reusing it threw `NotFoundError` in a strict DOM (WebKit) — and (b) clears the claim cursor while rebuilding the pending branch, so its static content actually builds instead of trying to claim discarded nodes. The test mini-DOM's `insertBefore` is now strict (throws on a non-child reference) so this class of bug surfaces in tests, not only the browser.
24
+
3
25
  ## 0.32.2
4
26
 
5
27
  ### Patch Changes
@@ -104,16 +126,16 @@
104
126
 
105
127
  - [`0cf889b`](https://github.com/briancray/abide/commit/0cf889b859b9169be2abd35c2624ff762bd08e91) - Add `@abide/inspector` — an opt-in inspector activated by `ABIDE_ENABLE_INSPECTOR=true`. Install it (`bun add -d @abide/inspector`) and the flag mounts a UI at `/__abide/inspector`.
106
128
 
107
- Three tabs over one event stream:
129
+ Three tabs over one event stream:
108
130
 
109
- - **Logs** (default) — a live tail of request traffic, app/diagnostic channels, cache tallies, and published socket frames, with filters by channel, trace id, and free text. Click a trace id to pivot the feed to that trace.
110
- - **Traces** — records grouped by trace id. Because abide propagates W3C trace context (a page session is one trace — the SSR render plus every RPC its interactions fire), a trace holds many requests; the tab splits each trace into nested per-request lanes (each on its own time axis, children indented under the request they descend from) with a span waterfall per request. Records now carry `requestSpan` / `parentSpan` (from the request's `TraceContext`) so this split is possible.
111
- - **Cache** — the persistent (`global: true`) cache store: each entry's key, lifecycle state (settled / in-flight / refreshing), kind, ttl, time-to-expiry, scope tags, invalidate policy, and a value preview. Refreshes on open and on demand. (Request-scoped caches are ephemeral, so they surface as per-request tallies in Logs/Traces instead.)
112
- - **Surface** — the static machine catalog: RPC verbs (with their declared `timeout` / `maxBodySize` / `crossOrigin` / `files` options as columns, and input/output JSON Schemas on expand) and sockets.
131
+ - **Logs** (default) — a live tail of request traffic, app/diagnostic channels, cache tallies, and published socket frames, with filters by channel, trace id, and free text. Click a trace id to pivot the feed to that trace.
132
+ - **Traces** — records grouped by trace id. Because abide propagates W3C trace context (a page session is one trace — the SSR render plus every RPC its interactions fire), a trace holds many requests; the tab splits each trace into nested per-request lanes (each on its own time axis, children indented under the request they descend from) with a span waterfall per request. Records now carry `requestSpan` / `parentSpan` (from the request's `TraceContext`) so this split is possible.
133
+ - **Cache** — the persistent (`global: true`) cache store: each entry's key, lifecycle state (settled / in-flight / refreshing), kind, ttl, time-to-expiry, scope tags, invalidate policy, and a value preview. Refreshes on open and on demand. (Request-scoped caches are ephemeral, so they surface as per-request tallies in Logs/Traces instead.)
134
+ - **Surface** — the static machine catalog: RPC verbs (with their declared `timeout` / `maxBodySize` / `crossOrigin` / `files` options as columns, and input/output JSON Schemas on expand) and sockets.
113
135
 
114
- The framework now self-instruments the request lifecycle with `log.trace` spans on DEBUG-gated diagnostic channels — `abide:render` (SSR render), `abide:view` (view/module resolution), `abide:rpc` (verb dispatch + validation; reveals in-process RPC→RPC calls as nested spans in the same trace), `abide:cache` (producer run on a miss + coalesced wait), `abide:mcp` (tool dispatch), and `abide:sockets` (REST tail/publish). They're zero-cost when their channel is off and fill the Traces waterfall when enabled (`DEBUG=abide:*`). The verb registry now records `timeout` / `maxBodySize` / `crossOrigin` so introspection can report them.
136
+ The framework now self-instruments the request lifecycle with `log.trace` spans on DEBUG-gated diagnostic channels — `abide:render` (SSR render), `abide:view` (view/module resolution), `abide:rpc` (verb dispatch + validation; reveals in-process RPC→RPC calls as nested spans in the same trace), `abide:cache` (producer run on a miss + coalesced wait), `abide:mcp` (tool dispatch), and `abide:sockets` (REST tail/publish). They're zero-cost when their channel is off and fill the Traces waterfall when enabled (`DEBUG=abide:*`). The verb registry now records `timeout` / `maxBodySize` / `crossOrigin` so introspection can report them.
115
137
 
116
- Core stays clean: a guarded, non-literal dynamic import keeps the package out of the compiled binary, and core injects an `InspectorContext` so the package imports no abide internals. Two passive observation seams feed the inspector — the existing log tap plus a new socket-frame tap in `defineSocket`'s `publish()` — both no-ops when no inspector is mounted. The inspector is privileged operator tooling: it answers ahead of `app.handle` and warns loudly on mount, so enable it only in trusted/dev environments.
138
+ Core stays clean: a guarded, non-literal dynamic import keeps the package out of the compiled binary, and core injects an `InspectorContext` so the package imports no abide internals. Two passive observation seams feed the inspector — the existing log tap plus a new socket-frame tap in `defineSocket`'s `publish()` — both no-ops when no inspector is mounted. The inspector is privileged operator tooling: it answers ahead of `app.handle` and warns loudly on mount, so enable it only in trusted/dev environments.
117
139
 
118
140
  ### Patch Changes
119
141
 
@@ -185,13 +207,13 @@
185
207
 
186
208
  - [`f9cd269`](https://github.com/briancray/abide/commit/f9cd269b44b065702275422ac710019117f69a2c) - fix(abide): `json(undefined)` returns 204 No Content instead of throwing
187
209
 
188
- `Response.json(undefined)` throws TypeError because JSON has no encoding for
189
- `undefined`, so any handler returning `json(undefined)` — e.g. a
190
- `Shape | undefined` route signalling "not found" — 500ed instead of degrading.
191
- `json()` now emits 204 No Content for `undefined`, which `decodeResponse`
192
- already maps back to `undefined` on both the fetch and in-process paths, so
193
- the `Shape | undefined` RPC contract round-trips the wire. The helper owns
194
- the 204; it wins over any `init.status`.
210
+ `Response.json(undefined)` throws TypeError because JSON has no encoding for
211
+ `undefined`, so any handler returning `json(undefined)` — e.g. a
212
+ `Shape | undefined` route signalling "not found" — 500ed instead of degrading.
213
+ `json()` now emits 204 No Content for `undefined`, which `decodeResponse`
214
+ already maps back to `undefined` on both the fetch and in-process paths, so
215
+ the `Shape | undefined` RPC contract round-trips the wire. The helper owns
216
+ the 204; it wins over any `init.status`.
195
217
 
196
218
  ## 0.25.1
197
219
 
@@ -263,7 +285,7 @@
263
285
 
264
286
  - [`d8fe8fc`](https://github.com/briancray/abide/commit/d8fe8fcae7863407d7b55774e19762715d311450) - **Fixed**
265
287
 
266
- - The lifecycle channel defers its notify to a microtask, coalescing marks within a tick. With a live `pending()`/`refreshing()` probe armed, a cold cache read inside a `$derived` — the documented `$derived(await cache(fn)())` idiom — registered its entry mid-derived and wrote the subscriber's version source synchronously, throwing `state_unsafe_mutation` and killing the flush (seen as an unhandled rejection plus a follow-on `active_reaction` TypeError, with UI updates in that batch silently dropped). Probes scan the registry at re-derive time, so the deferred ping reads state that is already current.
288
+ - The lifecycle channel defers its notify to a microtask, coalescing marks within a tick. With a live `pending()`/`refreshing()` probe armed, a cold cache read inside a `$derived` — the documented `$derived(await cache(fn)())` idiom — registered its entry mid-derived and wrote the subscriber's version source synchronously, throwing `state_unsafe_mutation` and killing the flush (seen as an unhandled rejection plus a follow-on `active_reaction` TypeError, with UI updates in that batch silently dropped). Probes scan the registry at re-derive time, so the deferred ping reads state that is already current.
267
289
 
268
290
  ## 0.23.0
269
291
 
@@ -271,52 +293,52 @@
271
293
 
272
294
  - [`13f841c`](https://github.com/briancray/abide/commit/13f841c4f3ec92727414715c1bb5eeabc12b60a7) - **Breaking**
273
295
 
274
- - `bundled()` moved to the bundle namespace alongside `onMenu`: `import { bundled } from '@abide/abide/bundle/bundled'` (was `abide/shared/bundled`).
296
+ - `bundled()` moved to the bundle namespace alongside `onMenu`: `import { bundled } from '@abide/abide/bundle/bundled'` (was `abide/shared/bundled`).
275
297
 
276
298
  - [`bbc82a1`](https://github.com/briancray/abide/commit/bbc82a12c948acbb2f82fe1dfc46165678fafb04) - Registries act, probes report: standalone `pending`/`refreshing` spanning cache and streams, the `ttl: 0` mutation idiom made sound, and tail reconnect-with-retained-value.
277
299
 
278
- **Breaking**
300
+ **Breaking**
279
301
 
280
- - `cache.pending` / `cache.refreshing` moved to their own modules: `import { pending } from '@abide/abide/shared/pending'`, `import { refreshing } from '@abide/abide/shared/refreshing'`. `cache.invalidate` stays on `cache`.
281
- - SSR snapshots ship GET entries only (`REPLAYABLE_METHODS`): DELETE is idempotent but still a write and no longer replays from hydration.
282
- - `cache()` now throws at wrap time on invalid policy combinations: `invalidate` policy on a non-GET remote, policy with `ttl: 0`, or `throttle` and `debounce` together.
302
+ - `cache.pending` / `cache.refreshing` moved to their own modules: `import { pending } from '@abide/abide/shared/pending'`, `import { refreshing } from '@abide/abide/shared/refreshing'`. `cache.invalidate` stays on `cache`.
303
+ - SSR snapshots ship GET entries only (`REPLAYABLE_METHODS`): DELETE is idempotent but still a write and no longer replays from hydration.
304
+ - `cache()` now throws at wrap time on invalid policy combinations: `invalidate` policy on a non-GET remote, policy with `ttl: 0`, or `throttle` and `debounce` together.
283
305
 
284
- **Fixed**
306
+ **Fixed**
285
307
 
286
- - Invalidate policies attach on read to entries that lack one (like scope tags), so a snapshot-hydrated entry revalidates stale-in-place from its first invalidate instead of hard-dropping to a pending flash, and a policy-less first read no longer permanently wins.
287
- - Hydrated snapshot entries adopt the first reading call site's `ttl` (the snapshot ships no wrap options): omitted keeps the entry as before, `ttl > 0` starts the expiry clock at that read, and `ttl: 0` serves the hydration pass only, evicting a macrotask later — previously any ttl was ignored, so a `ttl: 0` key warm-hit forever and never refetched.
288
- - Eviction clears armed policy timers — a TTL-expired or rejected key can no longer ghost-refetch.
308
+ - Invalidate policies attach on read to entries that lack one (like scope tags), so a snapshot-hydrated entry revalidates stale-in-place from its first invalidate instead of hard-dropping to a pending flash, and a policy-less first read no longer permanently wins.
309
+ - Hydrated snapshot entries adopt the first reading call site's `ttl` (the snapshot ships no wrap options): omitted keeps the entry as before, `ttl > 0` starts the expiry clock at that read, and `ttl: 0` serves the hydration pass only, evicting a macrotask later — previously any ttl was ignored, so a `ttl: 0` key warm-hit forever and never refetched.
310
+ - Eviction clears armed policy timers — a TTL-expired or rejected key can no longer ghost-refetch.
289
311
 
290
- **Added**
312
+ **Added**
291
313
 
292
- - `pending(x)` / `refreshing(x)` probe both registries: cache selectors (bare / fn / `{ scope }`) plus Subscribables — `pending(chat)` is "awaiting first frame", `refreshing(chat)` is "reconnecting with last value retained" (never merely open). Bare forms span registries. Probes report, never act: they open no fetch and no stream.
293
- - `tail()` (né `subscribe()` — renamed in this release) self-heals transport loss: on the typed `SocketDisconnectedError` it keeps its value, flags `refreshing`, and reopens under the channel's backoff (retained-tail replay converges the value — correct for a latest-wins/window consumer). Server `err` frames stay terminal; raw `for await` consumers keep the explicit-disconnect contract.
294
- - `cache()` warns once per call site when handed an anonymous producer (fresh identity per call — it can never coalesce and probes can never match it).
314
+ - `pending(x)` / `refreshing(x)` probe both registries: cache selectors (bare / fn / `{ scope }`) plus Subscribables — `pending(chat)` is "awaiting first frame", `refreshing(chat)` is "reconnecting with last value retained" (never merely open). Bare forms span registries. Probes report, never act: they open no fetch and no stream.
315
+ - `tail()` (né `subscribe()` — renamed in this release) self-heals transport loss: on the typed `SocketDisconnectedError` it keeps its value, flags `refreshing`, and reopens under the channel's backoff (retained-tail replay converges the value — correct for a latest-wins/window consumer). Server `err` frames stay terminal; raw `for await` consumers keep the explicit-disconnect contract.
316
+ - `cache()` warns once per call site when handed an anonymous producer (fresh identity per call — it can never coalesce and probes can never match it).
295
317
 
296
318
  - [`c77fc89`](https://github.com/briancray/abide/commit/c77fc89a12f07b9356b5ad68cd4a92f4764e52b8) - Replay is demarcated on the wire: a sub's seed now arrives as one per-sub `{ type: 'replay', sub, messages }` frame (sent even when empty) instead of socket-keyed `msg` frames.
297
319
 
298
- - `tail()` windows commit their seed atomically at the boundary — no more shrink-regrow rebuild on reconnect or first paint — and an **empty** replay keeps the held window across a gap (nothing replayed = nothing to duplicate; live frames append). Previously the first post-gap frame wiped the window even on non-retaining sockets.
299
- - Per-sub addressing fixes cross-sub replay leakage: two subs on the same socket no longer receive each other's replay.
300
- - `Subscribable.tail(count, hooks?)` gains optional `TailHooks`: retaining sources must signal `hooks.replayed` in-band once the seed is delivered (sockets do; a source that ends without signalling commits at `done`).
301
- - Internal: `createPushIterator` gains `control(run)` — in-band signal slots, strictly ordered against pushed values, invisible to consumers. Raw `for await` iteration is unchanged.
320
+ - `tail()` windows commit their seed atomically at the boundary — no more shrink-regrow rebuild on reconnect or first paint — and an **empty** replay keeps the held window across a gap (nothing replayed = nothing to duplicate; live frames append). Previously the first post-gap frame wiped the window even on non-retaining sockets.
321
+ - Per-sub addressing fixes cross-sub replay leakage: two subs on the same socket no longer receive each other's replay.
322
+ - `Subscribable.tail(count, hooks?)` gains optional `TailHooks`: retaining sources must signal `hooks.replayed` in-band once the seed is delivered (sockets do; a source that ends without signalling commits at `done`).
323
+ - Internal: `createPushIterator` gains `control(run)` — in-band signal slots, strictly ordered against pushed values, invisible to consumers. Raw `for await` iteration is unchanged.
302
324
 
303
325
  - [`c77fc89`](https://github.com/briancray/abide/commit/c77fc89a12f07b9356b5ad68cd4a92f4764e52b8) - `subscribe()` is now `tail()`, and "history" is dead: one word for reading the retained end of a stream at every altitude — declaration (`socket<T>({ tail: n })`), raw iteration (`chat.tail(count)`), and the reactive consumer (`tail(x)` / `tail(x, { last: n })`).
304
326
 
305
- **Breaking**
327
+ **Breaking**
306
328
 
307
- - `import { subscribe } from '@abide/abide/browser/subscribe'` → `import { tail } from '@abide/abide/browser/tail'`; `subscribe.status`/`subscribe.error` → `tail.status`/`tail.error` (both accept the same `{ last }` options to address a window entry).
308
- - Socket declaration option `history` → `tail` (`socket<T>({ tail: 100 })`). Retention is opt-in: an undeclared socket is a pure live pipe and storage is the consumer's concern.
309
- - Bare socket iteration (`for await (const m of chat)`) is now live-only — replay is exclusively `.tail`'s job. `chat.tail()` no-arg replays the whole retained tail (the old bare behavior); `chat.tail(n)` the last n. The ws wire is unchanged; only the local defaults flipped.
310
- - The reactive bare form seeds a socket via `tail(1)` instead of full replay — retained frames no longer churn through `latest` on open.
329
+ - `import { subscribe } from '@abide/abide/browser/subscribe'` → `import { tail } from '@abide/abide/browser/tail'`; `subscribe.status`/`subscribe.error` → `tail.status`/`tail.error` (both accept the same `{ last }` options to address a window entry).
330
+ - Socket declaration option `history` → `tail` (`socket<T>({ tail: 100 })`). Retention is opt-in: an undeclared socket is a pure live pipe and storage is the consumer's concern.
331
+ - Bare socket iteration (`for await (const m of chat)`) is now live-only — replay is exclusively `.tail`'s job. `chat.tail()` no-arg replays the whole retained tail (the old bare behavior); `chat.tail(n)` the last n. The ws wire is unchanged; only the local defaults flipped.
332
+ - The reactive bare form seeds a socket via `tail(1)` instead of full replay — retained frames no longer churn through `latest` on open.
311
333
 
312
- **Added**
334
+ **Added**
313
335
 
314
- - `tail(x, { last: n })` → `T[]`: a live rolling window of the last ≤n frames, however they arrived ([] while pending and on SSR, never undefined). Retaining sockets seed it by replaying up to `last` (clamped to the declared `tail`); rpc streams and undeclared sockets fill it from live frames. The bare form and each window size are independent subscriptions (`last` is registry-keyed). On reconnect the replay replaces the window, so a gap can't duplicate frames.
315
- - `Subscribable.tail(count)` is the optional retention capability: sources that keep recent frames implement it (sockets do verbatim) and the consumer bounds replay to what the reader keeps — no consumer special-casing per source type.
336
+ - `tail(x, { last: n })` → `T[]`: a live rolling window of the last ≤n frames, however they arrived ([] while pending and on SSR, never undefined). Retaining sockets seed it by replaying up to `last` (clamped to the declared `tail`); rpc streams and undeclared sockets fill it from live frames. The bare form and each window size are independent subscriptions (`last` is registry-keyed). On reconnect the replay replaces the window, so a gap can't duplicate frames.
337
+ - `Subscribable.tail(count)` is the optional retention capability: sources that keep recent frames implement it (sockets do verbatim) and the consumer bounds replay to what the reader keeps — no consumer special-casing per source type.
316
338
 
317
- **Fixed**
339
+ **Fixed**
318
340
 
319
- - An untracked `tail.status()`/`tail.error()` read (outside `$derived`/`$effect`) no longer leaks a permanently-pending registry entry that held the bare `pending()` probe true forever.
341
+ - An untracked `tail.status()`/`tail.error()` read (outside `$derived`/`$effect`) no longer leaks a permanently-pending registry entry that held the bare `pending()` probe true forever.
320
342
 
321
343
  ## 0.22.0
322
344
 
@@ -350,7 +372,7 @@
350
372
 
351
373
  - [`9cbca7d`](https://github.com/briancray/abide/commit/9cbca7d28c258d4574ac450811628f107e502711) - Bundle apps auto-start the local assistant. When a bundled abide app connects (embedded or remote) and the app ships `@abide/claude-code` (its UI uses `browser/assistant`) with `claude` on PATH, the bundle launcher runs the loopback bridge for you and hands the page its port+token via the URL fragment — no copy-paste command. The bridge is loopback-only and dies with the connection. abide takes **no dependency** on `@abide/claude-code`: it's a guarded optional import that no-ops (and compiles fine) when the app doesn't ship it.
352
374
 
353
- `assistant()` gains a `status` — `'ready' | 'starting' | 'manual' | 'unavailable'` — so the same UI works in a browser (`manual` → show `command`) and a bundle (`starting`/`ready` auto-managed, or `unavailable` when `claude` isn't installed → show an install hint). `command` is now `string | undefined` (undefined whenever a host manages the bridge).
375
+ `assistant()` gains a `status` — `'ready' | 'starting' | 'manual' | 'unavailable'` — so the same UI works in a browser (`manual` → show `command`) and a bundle (`starting`/`ready` auto-managed, or `unavailable` when `claude` isn't installed → show an install hint). `command` is now `string | undefined` (undefined whenever a host manages the bridge).
354
376
 
355
377
  ## 0.19.4
356
378
 
@@ -474,15 +496,15 @@
474
496
 
475
497
  - [#40](https://github.com/briancray/abide/pull/40) [`26ba6fe`](https://github.com/briancray/abide/commit/26ba6fe710002f84b99947f7b198fa3b3f235d53) Thanks [@briancray](https://github.com/briancray)! - Namespace the CLI's baked env under `ABIDE_` and add data-dir controls.
476
498
 
477
- **Breaking:** `APP_URL` → `ABIDE_APP_URL` and `APP_TOKEN` → `ABIDE_APP_TOKEN`. These are the values baked into a downloaded CLI's `.env` (the hosted server URL, derived from the request origin, plus the bearer token when the download was authenticated) and read by the thin client to resolve its connection target. `ABIDE_APP_URL` is now public, documented surface — app code can read it to refer to the app's hosted location. Existing baked binaries and any shell `APP_URL`/`APP_TOKEN` overrides must switch to the prefixed names.
499
+ **Breaking:** `APP_URL` → `ABIDE_APP_URL` and `APP_TOKEN` → `ABIDE_APP_TOKEN`. These are the values baked into a downloaded CLI's `.env` (the hosted server URL, derived from the request origin, plus the bearer token when the download was authenticated) and read by the thin client to resolve its connection target. `ABIDE_APP_URL` is now public, documented surface — app code can read it to refer to the app's hosted location. Existing baked binaries and any shell `APP_URL`/`APP_TOKEN` overrides must switch to the prefixed names.
478
500
 
479
- **Added:** `abide/server/appDataDir` — a zero-arg accessor returning the running bundle's per-user data dir, keyed to the same program name abide uses for the user's `.env`/`last-connection.json`, so an app's DB/cache lands beside abide's own config rather than a drifted sibling directory.
501
+ **Added:** `abide/server/appDataDir` — a zero-arg accessor returning the running bundle's per-user data dir, keyed to the same program name abide uses for the user's `.env`/`last-connection.json`, so an app's DB/cache lands beside abide's own config rather than a drifted sibling directory.
480
502
 
481
- **Added:** `ABIDE_DATA_DIR` — overrides the data dir on every platform, used as-is. A cross-platform `XDG_DATA_HOME` (which the helper otherwise honours on Linux only), letting dev point at a throwaway dir without touching app code. Must come from a layer above the data-dir `.env` (shell, CWD `.env`, or binary-dir `.env`), since it decides where that file lives.
503
+ **Added:** `ABIDE_DATA_DIR` — overrides the data dir on every platform, used as-is. A cross-platform `XDG_DATA_HOME` (which the helper otherwise honours on Linux only), letting dev point at a throwaway dir without touching app code. Must come from a layer above the data-dir `.env` (shell, CWD `.env`, or binary-dir `.env`), since it decides where that file lives.
482
504
 
483
505
  - [#40](https://github.com/briancray/abide/pull/40) [`310eceb`](https://github.com/briancray/abide/commit/310ecebebd018df62155d18ac01a376f5a0f42ba) Thanks [@briancray](https://github.com/briancray)! - Remove the `key` option from `cache()`.
484
506
 
485
- **Breaking:** `cache(fn, { key })` and the `{ key }` selector form of `cache.invalidate` / `cache.pending` are gone. Cache keys are always auto-derived — method+url+args for a remote function, producer-reference+args for a plain producer. To share an entry across calls, hoist the producer to a stable reference (an inline arrow gets a fresh identity every call and never dedupes). To target a set of unrelated calls with one `cache.invalidate`, tag them with a `scope`; a unique tag (e.g. a uuid) gives a set of calls their own private invalidation group.
507
+ **Breaking:** `cache(fn, { key })` and the `{ key }` selector form of `cache.invalidate` / `cache.pending` are gone. Cache keys are always auto-derived — method+url+args for a remote function, producer-reference+args for a plain producer. To share an entry across calls, hoist the producer to a stable reference (an inline arrow gets a fresh identity every call and never dedupes). To target a set of unrelated calls with one `cache.invalidate`, tag them with a `scope`; a unique tag (e.g. a uuid) gives a set of calls their own private invalidation group.
486
508
 
487
509
  ## 0.11.1
488
510
 
@@ -496,7 +518,7 @@
496
518
 
497
519
  - [`a1d1d56`](https://github.com/briancray/abide/commit/a1d1d56efe4887bebb74dd6707cf7cb38d8b4771) - `cache` and `HttpError` move from the `browser`/`server` namespaces to `shared`, which now denotes the isomorphic surface — names that are the same callable with the same behaviour on both sides. `cache()` runs in SSR and MCP request scope just as it does on the client, so importing it as a "browser" module misrepresented it; its client-only streaming/hydration helpers stay in `browser/` and its server-only snapshot helpers stay in `server/runtime/`. Update imports: `abide/browser/cache` → `abide/shared/cache`, and `abide/browser/HttpError` (or `abide/server/HttpError`) → `abide/shared/HttpError`.
498
520
 
499
- The package `exports` map is now an explicit allowlist of the public API instead of per-directory `*` globs, so internal modules (machinery under `shared/`, runtime/registry internals under `server/`, launcher internals under `bundle/`, and all `types/` subtrees) are no longer reachable via the package specifier. Only documented names — the verb/response/context helpers, `cache`, `HttpError`, `page`/`navigate`/`subscribe`, the `bundle` window config, the test client, and the build/plugin entries — resolve. Importing an unlisted internal path now fails; use the public name instead.
521
+ The package `exports` map is now an explicit allowlist of the public API instead of per-directory `*` globs, so internal modules (machinery under `shared/`, runtime/registry internals under `server/`, launcher internals under `bundle/`, and all `types/` subtrees) are no longer reachable via the package specifier. Only documented names — the verb/response/context helpers, `cache`, `HttpError`, `page`/`navigate`/`subscribe`, the `bundle` window config, the test client, and the build/plugin entries — resolve. Importing an unlisted internal path now fails; use the public name instead.
500
522
 
501
523
  - [`a1d1d56`](https://github.com/briancray/abide/commit/a1d1d56efe4887bebb74dd6707cf7cb38d8b4771) - `cache()` now memoises plain producers, not just rpc verb helpers — pass any `() => Promise<T>` to dedupe and retain external calls (e.g. a third-party `fetch` the server makes). Producers key on the function's reference plus args (so hoist the function, or pass an explicit `key`; an inline arrow is a fresh reference every call and never dedupes), and the value promise is stored as-is — no Response decode and no SSR snapshot. A new `global: true` option puts the entry in a process-level store instead of the request-scoped one, so a value computed in one request is reused by later ones; omit it to keep per-request data from leaking across requests, and note it is a no-op on the client (one tab store). `cache.invalidate` / `cache.pending` accept a producer reference and span both stores.
502
524
 
@@ -506,13 +528,13 @@
506
528
 
507
529
  - [`a1d1d56`](https://github.com/briancray/abide/commit/a1d1d56efe4887bebb74dd6707cf7cb38d8b4771) - Run in-process rpc dispatch inside the request scope for the MCP tool dispatcher and the in-process CLI client. Previously both invoked handlers without a per-request scope, so `cache()` silently shared one process-wide store across calls (leaking state between unrelated tool/CLI invocations) and `cookies()`/`request()` threw. Both now cross the same `runWithRequestScope` seam the HTTP router uses, giving per-call cache isolation and resolving the scope-bound helpers.
508
530
 
509
- Behavior change for MCP: a tool handler that throws is now caught by the scope and returned as a tool result with `isError: true` (framed from the 500 response), instead of surfacing as a JSON-RPC `-32603` error on the envelope. The JSON-RPC call itself succeeds; the failure is reported at the tool-result level, which is the correct MCP shape.
531
+ Behavior change for MCP: a tool handler that throws is now caught by the scope and returned as a tool result with `isError: true` (framed from the 500 response), instead of surfacing as a JSON-RPC `-32603` error on the envelope. The JSON-RPC call itself succeeds; the failure is reported at the tool-result level, which is the correct MCP shape.
510
532
 
511
533
  - [`a1d1d56`](https://github.com/briancray/abide/commit/a1d1d56efe4887bebb74dd6707cf7cb38d8b4771) - Project JSON Schema from a schema's own `toJSONSchema()` everywhere it's needed (OpenAPI, MCP tools, CLI flags, the bundle setup form). Drop the `inputJsonSchema` / `outputJsonSchema` / `filesJsonSchema` verb opts and the socket `jsonSchema` opt — a schema whose library doesn't expose a method wraps once with the new `abide/shared/withJsonSchema` helper. Multipart file parts are now advertised generically as binary in OpenAPI rather than named per field.
512
534
 
513
- Add `src/server/config.ts` as the home for typed env: `export const config = env(schema)`, imported as `$server/config` and eager-imported at boot so validation fails fast. The file is optional and scaffolded — when absent you read `Bun.env` directly.
535
+ Add `src/server/config.ts` as the home for typed env: `export const config = env(schema)`, imported as `$server/config` and eager-imported at boot so validation fails fast. The file is optional and scaffolded — when absent you read `Bun.env` directly.
514
536
 
515
- The bundle's first-run setup form is now derived from that same env schema by default, so one declaration drives boot validation and the form. `BundleWindow.config` still works but now _replaces_ the derived schema (for a form that should differ from the env schema) rather than being the only source.
537
+ The bundle's first-run setup form is now derived from that same env schema by default, so one declaration drives boot validation and the form. `BundleWindow.config` still works but now _replaces_ the derived schema (for a form that should differ from the env schema) rather than being the only source.
516
538
 
517
539
  - [`a1d1d56`](https://github.com/briancray/abide/commit/a1d1d56efe4887bebb74dd6707cf7cb38d8b4771) - Add two server primitives. `abide/server/env` validates the process environment against a Standard Schema at module load, returning typed config and failing the boot with every issue listed when a variable is missing or malformed. `abide/server/cookies` exposes the request's cookie jar — Bun's native `CookieMap` parsed from the inbound `Cookie` header, with `set`/`delete` writes flushed to `Set-Cookie` on the outgoing response when the handler returns. `cookies` resolves from the request scope like `request()`, materialized lazily so a request that never touches them parses and emits nothing; `env` reads `Bun.env` once at module load, independent of any request.
518
540
 
@@ -610,12 +632,12 @@
610
632
 
611
633
  - [#10](https://github.com/briancray/abide/pull/10) [`6ceb71b`](https://github.com/briancray/abide/commit/6ceb71b28e3b1a4c9726483d2c7dd3f40be3be59) Thanks [@briancray](https://github.com/briancray)! - Bundles now resolve config from a cwd-independent source instead of relying on Bun's cwd-based `.env` autoload (which a launched `.app`, whose cwd is `/`, silently misses). Config flows entirely through `process.env`, so app code keeps reading `Bun.env.*` and never learns where a value came from.
612
634
 
613
- - The compiled server loads two `.env` layers into `process.env` at boot, before anything reads it: the per-user data dir first (user config), then the binary dir (shipped default). Both back-fill only what a shell export or Bun's CWD `.env` didn't already set, so the precedence is `shell > CWD .env > data-dir .env > binary-dir .env > code default`.
614
- - Add `abide/shared/appDataDir` — the platform-standard per-user data directory keyed by program name, where the data-dir `.env` lives.
615
- - `abide bundle` ships an optional project `.env.bundle` as the binary-dir `.env` (the shipped default layer). Skipped when absent; use a dedicated file, never the working `.env`, since a compiled bundle is extractable.
616
- - Start now races server readiness against the child's exit, so a misconfigured bundle reports the crash immediately instead of stalling for the full readiness timeout.
617
- - A bundle resolves its last connection before the window opens: the launcher records the choice (embedded, or a remote URL) in the data dir, and on relaunch boots/probes it first, opening the window straight at the live server — so the connect screen never flashes. A boot that fails or exceeds a short ceiling, an unconfigured embedded resume, a dead saved server, or no saved choice falls back to opening the connect screen.
618
- - A bundle can declare `config` on its `BundleWindow` as a Standard Schema (the same kind abide accepts for RPC/MCP). Its JSON Schema drives a first-run settings modal on the connect screen — `title` → label, `description` → hint, `format: 'password'` → masked input, `default` → prefill — and answers persist to the data-dir `.env`. An explicit Start (button or File-menu click) always opens the modal prefilled with the last-used values, so re-running Start after a disconnect is how you reconfigure; an auto-start on relaunch never opens the modal — it boots a fully-configured app, or stays on the connect screen when a required key is still unset. Apps with no schema always boot straight through.
635
+ - The compiled server loads two `.env` layers into `process.env` at boot, before anything reads it: the per-user data dir first (user config), then the binary dir (shipped default). Both back-fill only what a shell export or Bun's CWD `.env` didn't already set, so the precedence is `shell > CWD .env > data-dir .env > binary-dir .env > code default`.
636
+ - Add `abide/shared/appDataDir` — the platform-standard per-user data directory keyed by program name, where the data-dir `.env` lives.
637
+ - `abide bundle` ships an optional project `.env.bundle` as the binary-dir `.env` (the shipped default layer). Skipped when absent; use a dedicated file, never the working `.env`, since a compiled bundle is extractable.
638
+ - Start now races server readiness against the child's exit, so a misconfigured bundle reports the crash immediately instead of stalling for the full readiness timeout.
639
+ - A bundle resolves its last connection before the window opens: the launcher records the choice (embedded, or a remote URL) in the data dir, and on relaunch boots/probes it first, opening the window straight at the live server — so the connect screen never flashes. A boot that fails or exceeds a short ceiling, an unconfigured embedded resume, a dead saved server, or no saved choice falls back to opening the connect screen.
640
+ - A bundle can declare `config` on its `BundleWindow` as a Standard Schema (the same kind abide accepts for RPC/MCP). Its JSON Schema drives a first-run settings modal on the connect screen — `title` → label, `description` → hint, `format: 'password'` → masked input, `default` → prefill — and answers persist to the data-dir `.env`. An explicit Start (button or File-menu click) always opens the modal prefilled with the last-used values, so re-running Start after a disconnect is how you reconfigure; an auto-start on relaunch never opens the modal — it boots a fully-configured app, or stays on the connect screen when a required key is still unset. Apps with no schema always boot straight through.
619
641
 
620
642
  ### Patch Changes
621
643
 
@@ -681,12 +703,12 @@
681
703
 
682
704
  - [`cf136c7`](https://github.com/briancray/abide/commit/cf136c7b763283570ef431b3aad269626bea7824) - Add a `abide bundle` desktop app and make the CLI a thin remote-only client.
683
705
 
684
- - `abide bundle` assembles a movable, self-contained desktop app (a `.app` on macOS, a flat dir elsewhere) that boots into a connect screen — start the embedded server or connect to a remote one by URL.
685
- - **Breaking:** the CLI binary is now always a thin remote client (talks to a running server over HTTP, `APP_URL` required). Dropped the `--thin`/full split and in-process fallback — use `abide bundle` for the embedded-backend case.
686
- - **Breaking:** MCP prompts are now markdown files (`src/mcp/prompts/**.md`) with YAML frontmatter, replacing the `.ts` prompt modules.
687
- - **Breaking:** handlers read the inbound request via `request()` and the live server via `server()` rather than `RequestStore` fields.
688
- - `json` / `jsonl` / `sse` / `error` / `redirect` accept a trailing `ResponseInit`.
689
- - Static-asset header caching is shared across asset servers, and zstd decompression moved to the async API.
706
+ - `abide bundle` assembles a movable, self-contained desktop app (a `.app` on macOS, a flat dir elsewhere) that boots into a connect screen — start the embedded server or connect to a remote one by URL.
707
+ - **Breaking:** the CLI binary is now always a thin remote client (talks to a running server over HTTP, `APP_URL` required). Dropped the `--thin`/full split and in-process fallback — use `abide bundle` for the embedded-backend case.
708
+ - **Breaking:** MCP prompts are now markdown files (`src/mcp/prompts/**.md`) with YAML frontmatter, replacing the `.ts` prompt modules.
709
+ - **Breaking:** handlers read the inbound request via `request()` and the live server via `server()` rather than `RequestStore` fields.
710
+ - `json` / `jsonl` / `sse` / `error` / `redirect` accept a trailing `ResponseInit`.
711
+ - Static-asset header caching is shared across asset servers, and zstd decompression moved to the async API.
690
712
 
691
713
  ## 0.1.0
692
714
 
package/bin/abide.ts CHANGED
@@ -36,8 +36,11 @@ handler the parent's default action kills it instantly — abandoning the
36
36
  `await child.exited` and orphaning the child, which (for a server) can then
37
37
  linger holding the port. Forwarding the signal and awaiting the child's exit
38
38
  (with a SIGKILL watchdog for a wedged child) guarantees the child is reaped
39
- before the parent leaves. Mirrors the child's exit code so callers and CI see
40
- the real result.
39
+ before the parent leaves. SIGHUP (terminal close) is forwarded too: if Bun
40
+ spawns the child in its own process group the kernel's terminal-close signal
41
+ never reaches it, so the child only learns to shut down by this relay — without
42
+ it a closed terminal orphans the child on the port. Mirrors the child's exit
43
+ code so callers and CI see the real result.
41
44
  */
42
45
  async function runChild(cmd: string[]): Promise<never> {
43
46
  const child = Bun.spawn({ cmd, cwd, stdio: ['inherit', 'inherit', 'inherit'] })
@@ -47,6 +50,7 @@ async function runChild(cmd: string[]): Promise<never> {
47
50
  }
48
51
  process.on('SIGINT', () => forward('SIGINT'))
49
52
  process.on('SIGTERM', () => forward('SIGTERM'))
53
+ process.on('SIGHUP', () => forward('SIGHUP'))
50
54
  process.exit(await child.exited)
51
55
  }
52
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.32.2",
3
+ "version": "0.33.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -81,11 +81,14 @@
81
81
  "./ui/dom/mountChild": "./src/lib/ui/dom/mountChild.ts",
82
82
  "./ui/dom/hydrate": "./src/lib/ui/dom/hydrate.ts",
83
83
  "./ui/dom/text": "./src/lib/ui/dom/text.ts",
84
- "./ui/dom/openChild": "./src/lib/ui/dom/openChild.ts",
85
84
  "./ui/dom/appendText": "./src/lib/ui/dom/appendText.ts",
85
+ "./ui/dom/appendTextAt": "./src/lib/ui/dom/appendTextAt.ts",
86
86
  "./ui/dom/appendSnippet": "./src/lib/ui/dom/appendSnippet.ts",
87
87
  "./ui/dom/appendStatic": "./src/lib/ui/dom/appendStatic.ts",
88
88
  "./ui/dom/cloneStatic": "./src/lib/ui/dom/cloneStatic.ts",
89
+ "./ui/dom/skeleton": "./src/lib/ui/dom/skeleton.ts",
90
+ "./ui/dom/anchorCursor": "./src/lib/ui/dom/anchorCursor.ts",
91
+ "./ui/dom/mountSlot": "./src/lib/ui/dom/mountSlot.ts",
89
92
  "./ui/dom/attr": "./src/lib/ui/dom/attr.ts",
90
93
  "./ui/dom/on": "./src/lib/ui/dom/on.ts",
91
94
  "./ui/dom/attach": "./src/lib/ui/dom/attach.ts",
@@ -151,6 +154,7 @@
151
154
  "devDependencies": {
152
155
  "@abide/inspector": "workspace:*",
153
156
  "bun-plugin-tailwind": "^0.1.2",
157
+ "happy-dom": "^20.10.6",
154
158
  "tailwindcss": "^4.0.0"
155
159
  }
156
160
  }
package/src/devEntry.ts CHANGED
@@ -81,7 +81,18 @@ function spawnWorker(port: number): { proc: Subprocess; ready: Promise<void> } {
81
81
  const proc = Bun.spawn({
82
82
  cmd: ['bun', '--preload', PRELOAD, SERVER_ENTRY],
83
83
  cwd,
84
- env: { ...process.env, PORT: String(port), ABIDE_DEV: '1' },
84
+ /*
85
+ ABIDE_PARENT_PID activates serverEntry's exitWithParent watchdog: the
86
+ worker polls this orchestrator and self-exits if it dies abruptly
87
+ (kill -9, OOM) without running its shutdown handlers, so a wedged
88
+ orchestrator can't leave the worker orphaned holding the dev port.
89
+ */
90
+ env: {
91
+ ...process.env,
92
+ PORT: String(port),
93
+ ABIDE_DEV: '1',
94
+ ABIDE_PARENT_PID: String(process.pid),
95
+ },
85
96
  stdio: ['inherit', 'inherit', 'inherit'],
86
97
  // The child's POST /__abide/reload route signals a rebuild over IPC, so the
87
98
  // trigger rides the app's own port instead of a side channel.
@@ -1,12 +1,12 @@
1
1
  /*
2
- Tie the embedded server's lifetime to the bundle launcher's.
2
+ Tie this server's lifetime to its launcher's.
3
3
 
4
- The launcher spawns this server with ABIDE_PARENT_PID set to its own pid. On a
5
- clean window close the launcher reaps the child directly, but a force-quit (or
6
- crash) of the launcher can't run that cleanup, which would leave the server
7
- orphaned and holding its port. So when that env var is present, poll the parent
8
- and exit once it's gone. A no-op when the var is absent (standalone `abide
9
- start`), so it only ever activates inside a bundle.
4
+ The launcher (a bundle, or the dev orchestrator) spawns this server with
5
+ ABIDE_PARENT_PID set to its own pid. On a clean shutdown the launcher reaps the
6
+ child directly, but a force-quit or crash of the launcher can't run that
7
+ cleanup, which would leave the server orphaned and holding its port. So when
8
+ that env var is present, poll the parent and exit once it's gone. A no-op when
9
+ the var is absent (standalone `abide start`).
10
10
  */
11
11
  export function exitWithParent(): void {
12
12
  const parent = process.env.ABIDE_PARENT_PID
@@ -18,19 +18,20 @@ function preview(value: unknown): string | undefined {
18
18
  }
19
19
  }
20
20
 
21
- /* The entry's armed invalidate policy as a label, if it declared one. */
21
+ /* The entry's armed swr policy as a label, if it declared one. A bare `swr: true`
22
+ has no window, so it labels as plain `swr`. */
22
23
  function policyLabel(entry: CacheEntry): string | undefined {
23
24
  const policy = entry.invalidation
24
25
  if (!policy) {
25
26
  return undefined
26
27
  }
27
28
  if (policy.debounce !== undefined) {
28
- return `debounce ${policy.debounce}ms`
29
+ return `swr debounce ${policy.debounce}ms`
29
30
  }
30
31
  if (policy.throttle !== undefined) {
31
- return `throttle ${policy.throttle}ms`
32
+ return `swr throttle ${policy.throttle}ms`
32
33
  }
33
- return undefined
34
+ return 'swr'
34
35
  }
35
36
 
36
37
  function projectEntry(entry: CacheEntry, now: number): InspectorCacheEntry {
@@ -19,6 +19,6 @@ export type InspectorCacheEntry = {
19
19
  scope: string[]
20
20
  /* A short JSON preview of the decoded warm value, when the entry holds one. */
21
21
  value: string | undefined
22
- /* An armed invalidate policy (throttle/debounce + ms), if declared. */
22
+ /* An armed swr policy (`swr`, optionally + throttle/debounce + ms), if declared. */
23
23
  policy: string | undefined
24
24
  }
@@ -215,31 +215,48 @@ export function cache<Args, Return>(
215
215
  return read
216
216
  }
217
217
 
218
+ /*
219
+ Normalises the `swr` option to its window, or undefined when off. `true` (or
220
+ `{}`) is stale-while-revalidate with no window — refetch immediately on every
221
+ invalidate; an object carries the throttle/debounce window. `false`/omitted is
222
+ off. Collapsing the boolean here lets every downstream site treat "is SWR on"
223
+ as a single defined/undefined check.
224
+ */
225
+ function swrWindow(
226
+ options: CacheOptions | undefined,
227
+ ): { throttle?: number; debounce?: number } | undefined {
228
+ const swr = options?.swr
229
+ if (swr === undefined || swr === false) {
230
+ return undefined
231
+ }
232
+ return swr === true ? {} : swr
233
+ }
234
+
218
235
  /*
219
236
  Guards impossible option combinations at wrap time, where the call site is on
220
- the stack. A policy declares "this call is safe to re-run unprompted", so a
221
- non-replayable remote method (a write) must never carry one — replaying a write
237
+ the stack. `swr` declares "this call is safe to re-run unprompted", so a
238
+ non-replayable remote method (a write) must never carry it — replaying a write
222
239
  through the invalidation grammar would be a state change disguised as a
223
240
  refresh. Producers are opaque (no method to check); the same contract is on
224
- the caller there. ttl: 0 retains nothing, so there is nothing for a policy to
225
- revalidate; and the two coalescing strategies are exclusive by construction.
241
+ the caller there. ttl: 0 retains nothing, so there is nothing to revalidate;
242
+ and the two coalescing windows are exclusive by construction.
226
243
  */
227
244
  function validatePolicy(options: CacheOptions | undefined, method: string | undefined): void {
228
- const policy = options?.invalidate
229
- if (!policy || (policy.throttle === undefined && policy.debounce === undefined)) {
245
+ const policy = swrWindow(options)
246
+ if (!policy) {
230
247
  return
231
248
  }
232
249
  if (policy.throttle !== undefined && policy.debounce !== undefined) {
233
- throw new Error('[abide] cache(): set invalidate.throttle or invalidate.debounce, not both')
250
+ throw new Error('[abide] cache(): set swr.throttle or swr.debounce, not both')
234
251
  }
235
252
  if (options?.ttl === 0) {
236
253
  throw new Error(
237
- '[abide] cache(): an invalidate policy requires retention — ttl: 0 keeps nothing to revalidate',
254
+ '[abide] cache(): swr requires retention — ttl: 0 keeps nothing to revalidate',
238
255
  )
239
256
  }
240
257
  if (method !== undefined && !REPLAYABLE_METHODS.has(method.toUpperCase())) {
241
258
  throw new Error(
242
- `[abide] cache(): an invalidate policy re-runs the call unprompted — ${method.toUpperCase()} is a write and must not be replayed`,
259
+ `[abide] cache(): swr re-runs the call unprompted — ${method.toUpperCase()} is a write and must not be replayed`,
243
260
  )
244
261
  }
245
262
  }
@@ -334,12 +351,11 @@ function registerEntry(
334
351
  refetch: () => Promise<unknown>,
335
352
  ): CacheEntry {
336
353
  const ttl = options?.ttl
337
- /* Capture the refetch thunk + policy only when an invalidate window was asked for. */
338
- const policy = options?.invalidate
339
- const invalidation =
340
- policy?.throttle !== undefined || policy?.debounce !== undefined
341
- ? { refetch, throttle: policy.throttle, debounce: policy.debounce }
342
- : undefined
354
+ /* Capture the refetch thunk + window only when swr was asked for. */
355
+ const policy = swrWindow(options)
356
+ const invalidation = policy
357
+ ? { refetch, throttle: policy.throttle, debounce: policy.debounce }
358
+ : undefined
343
359
  /*
344
360
  A prior entry for this key was dropped by invalidate() and is awaiting its
345
361
  next read — consume the marker so this replacement read reports as a reload
@@ -477,7 +493,7 @@ Invalidates every entry matching the selector (see selectorMatcher) across both
477
493
  the request/tab store and the process-level store, and notifies readers.
478
494
  `args` narrows a fn selector to exactly that call's entry — derived through
479
495
  the same encoders the read path uses, so other args variants stay warm. An entry
480
- with an invalidate throttle/debounce policy is kept and its refetch coalesced (stale served
496
+ with an swr policy is kept and its refetch coalesced (stale served
481
497
  until it resolves); every other match is dropped so the next read refetches —
482
498
  its key recorded in pendingRefresh so that read reports as a reload (refreshing())
483
499
  rather than a first-ever load. An empty or unmatched selector is a no-op on the
@@ -722,9 +738,9 @@ cache.on = on
722
738
  Optimistic write: applies `updater` as a prediction now — the reactive read
723
739
  shows it immediately — runs `call`, then reconciles. On resolve the server is
724
740
  the truth: the prediction is dropped and the selector invalidated, so the value
725
- refetches authoritatively, coalesced per the read's own invalidate policy
726
- (cache(fn, { invalidate: { throttle } }) bounds an optimistic-write storm with no
727
- extra knob here; without a policy it is a plain drop-and-refetch). On reject the
741
+ refetches authoritatively, coalesced per the read's own swr window
742
+ (cache(fn, { swr: { throttle } }) bounds an optimistic-write storm with no
743
+ extra knob here; without swr it is a plain drop-and-refetch). On reject the
728
744
  prediction rolls back. The returned promise is transparent over `call` —
729
745
  resolves to `call`'s value (the mutation result, e.g. a created id), rejects
730
746
  with its error, settling only after the cache reflects the reconciled state: an
@@ -769,10 +785,12 @@ function patch<Args, Return, Result>(
769
785
  cache.patch = patch
770
786
 
771
787
  /*
772
- Schedules a coalesced refetch per the entry's invalidate policy. debounce: (re)arm
773
- a timer that fires after N ms of quiet. throttle: fire on the leading edge when a
774
- full window has elapsed since the last fire, else arm a single trailing timer for
775
- the remainder so a continuous invalidation stream refetches at most once per window.
788
+ Schedules a coalesced refetch per the entry's swr policy. No window (swr: true):
789
+ fire immediately (throttle defaults to 0, so the leading-edge branch always
790
+ takes). debounce: (re)arm a timer that fires after N ms of quiet. throttle: fire
791
+ on the leading edge when a full window has elapsed since the last fire, else arm
792
+ a single trailing timer for the remainder — so a continuous invalidation stream
793
+ refetches at most once per window.
776
794
  */
777
795
  function scheduleInvalidationRefetch(store: CacheStore, entry: CacheEntry): void {
778
796
  const policy = entry.invalidation
@@ -932,12 +950,8 @@ function attachPolicy(
932
950
  options: CacheOptions | undefined,
933
951
  refetch: () => Promise<unknown>,
934
952
  ): void {
935
- const policy = options?.invalidate
936
- if (
937
- entry.invalidation ||
938
- !policy ||
939
- (policy.throttle === undefined && policy.debounce === undefined)
940
- ) {
953
+ const policy = swrWindow(options)
954
+ if (entry.invalidation || !policy) {
941
955
  return
942
956
  }
943
957
  entry.invalidation = { refetch, throttle: policy.throttle, debounce: policy.debounce }