Development workflow #
This page is the maintainer reference for day-to-day fen development, hot reload, local checks, and disposable build artifacts.
Workflow #
Normal development uses one single-file fen binary plus source overlays. No generated Lua tree is needed for .fnl edits:
make dev-nix # nix build .#fen, then scripts/dev/fen-dev
make dev-portable # make fen, then scripts/dev/fen-dev with build/fen
# or, if FEN_BIN is set / fen is on PATH:
make dev
# edit .fnl, then /reload in the running TUI
scripts/dev/fen-dev sets FEN_DEV_PATH for package source roots and FEN_EXTENSION_ROOT for extensions/, so /reload sees checkout source.
Fennel formatting #
make install-hooks # once per clone: pre-commit checks staged .fnl files
make fmt # format .fnl files changed since origin/main
make fmt FILES='a.fnl b.fnl' # format specific files
make fmt-check # check without rewriting (also part of make check)
scripts/format/check.fnl wraps the pinned fnlfmt in scripts/format/vendor/; its header documents the modes. Changed files are those differing from the merge base with FMT_BASE (default origin/main), including uncommitted and untracked .fnl files. Formatting is incremental: untouched files are grandfathered, but touching an unformatted file means formatting all of it. The pre-commit hook checks the Git index, never rewrites it, and applies to every worktree of the clone. CI runs make fmt-check against the PR base or the previous push.
Fast checks while editing:
fennel scripts/test/fennel-check.fnl
make test # fast Busted suite; excludes tests tagged #slow
make test-all # full Busted suite, including #slow integration/discovery tests
make test TESTS=path/to/test.fnl # focused test-file run (includes #slow tests in that selection)
make test BUSTED_ARGS='--filter=foo' # focused fast test-name run
make test-list # inspect Busted names/tags without running
make test-shuffle REPEAT=3 # shake out order/state leakage
make smoke-mock # deterministic local provider/tool smoke
make check # fennel-check + format check + doc validation + tests
make test is an alias for make test-fast, the normal edit-loop suite that excludes Busted tests tagged #slow when no files are selected. An explicit TESTS=... selection is passed through without that exclusion, so a named slow test always runs. Run make test-all before merging when the focused loop is insufficient; make check and CI always run the full suite. TESTS selects files or directories passed to Busted. BUSTED_ARGS is passed through to Busted for runner-level affordances such as --filter, --name, --tags, --exclude-tags, --shuffle, --repeat, and --list. Keep paths in TESTS; BUSTED_ARGS is intentionally shell-split for ordinary option flags. Directory-focused TESTS=.../tests runs keep tests/smoke/ excluded unless FEN_INCLUDE_SMOKE_TESTS=1 is set or a smoke file is passed explicitly.
TUI behavior has two complementary test layers. Fast Busted tests run in-process under extensions/adapters/presenters/tui/tests/ and stub termbox2 through fen.testing.tui. Use these tests for transcript viewport logic, key/input state machines, rendering rows, cache invalidation, and deterministic regressions that can be asserted from state, returned rows, or a capture-enabled virtual screen. Pass {:capture? true :cols N :rows N} to fen.testing.tui.install-termbox-stub! when a test needs whole-frame text assertions; screen-lines returns normalized back-buffer rows and presented-screen-lines returns the last presented frame. That capture path is the substrate for story-style fixtures and future golden TUI snapshots. Fast tests should run under normal make test and should not open a real terminal. The opt-in real-PTY smoke layer runs under make test-pty and is reserved for terminal integration, resize behavior that needs a real PTY, redraw/performance metrics, and smoke artifacts. It uses a test-only native PTY helper from packages/testing/vendor/ and does not use libvirt or a VM. The initial smoke records raw PTY output, an asciinema v2 session.cast, and metrics.json under tmp/tui-pty/.
Run make profile-tui-scroll to automate a focused rapid mouse-wheel capture through a real PTY. The scenario builds a long deterministic transcript, starts the Lua profiler, queues a wheel-event burst, fences it with a visible command, and saves profile.speedscope.json, profile.folded, profile.json, PTY bytes, and burst wall time in a timestamped tmp/tui-pty/*-scroll-profile/ directory. Use FEN_SCROLL_PROFILE_EVENTS, FEN_SCROLL_PROFILE_WHEELS, and FEN_SCROLL_PROFILE_PERIOD to tune the transcript size, burst size, and sampling overhead for slow ARM devices. For example, FEN_SCROLL_PROFILE_PERIOD=100000 make profile-tui-scroll is an appropriate starting point on an N900. The Lua samples expose viewport and paint work, while a large burst wall time with sparse Lua samples still points toward native tb.present() or terminal I/O. Run make check-tui-scroll-perf for the same capture with a default 250 ms burst budget, or set FEN_SCROLL_PROFILE_MAX_MS to a machine-specific regression ceiling. The budget is intentionally opt-in rather than part of make check because PTY wall time varies across hosts; compare repeated runs on the same machine when evaluating an optimization.
Input and streaming visibility latency #
Run make bench-tui-latency from the repository root with Fennel and LuaSocket available. This is separate from make bench-tui, which retains the CPU microbenchmarks. Use make bench-tui-latency ARGS=--logical-only for a fast deterministic scheduling baseline without sleeping. The default additionally replays each scenario against real wall time (about 12 seconds total, plus module loading).
The benchmark drives the production tui.run loop, real input dispatcher, registered event bus, stream ingestion, Markdown renderer, layout, redraw flags, and captured termbox presentation. Only the clock, terminal event source, terminal output, and provider event source are controlled boundaries; no production behavior is changed. Keys wake the scheduled termbox poll, but provider deltas become available only when the loop calls on-tick, preserving the current 30 ms active poll and 300 ms idle poll. Every input sample checks the accumulated draft in a presented frame; every delta has a unique visible marker and is measured from its scheduled availability, not from its eventual ingestion. The runner fails if any scheduled sample is undelivered or never appears, rather than silently dropping slow samples from the statistics.
Scenarios cover idle and active typing, 18 slow 8-byte deltas spaced 80 ms apart, a 24-delta burst spaced 1 ms apart, typing during streaming, and the same interaction after 1,000 mixed historical rows. The long history contains wrapped Markdown, code blocks, user prompts, and folded tool results. All scenarios use a 100×32 terminal, Markdown enabled, animations at their production defaults, and a cold initial frame; the long-history scenario also has a thinking/busy panel. Each scenario is a two-second replay with a stream-end flush where applicable. The report gives sample counts and median, nearest-rank p95, and maximum input/delta latency in milliseconds, separately for logical and wall replays. It also gives frame CPU median/p95/max, total process CPU, total run wall duration, presentation counts, and tick counts. Frame CPU includes real paint plus the screen-capture and visibility-observer overhead; total CPU also includes ticks and input handling, but excludes initial fixture seeding and eager module loading. Lazy imports during the first replay can make its total CPU higher; repeat the invocation and compare the same scenario on the same hardware.
Logical time advances for scheduled polling and injected stalls, but does not pretend host CPU execution takes virtual time. Wall replay actually waits, includes host rendering/capture cost and scheduler oversleep, and timestamps completion of the captured tb.present call using LuaSocket's wall clock. Wall-clock adjustments during a run can perturb that mode; logical mode has no host-clock dependence. These are in-process presentation measurements, not terminal-emulator pixels or end-to-end network latency. They exclude native termbox diffing/write backpressure, SSH/mosh/tmux, provider transport/parsing, and non-cooperative production tool work. No machine-independent wall budget is enforced, and 18–24 samples per channel make p95 a diagnostic, not a precise population estimate.
The deterministic regression suite is make test TESTS=extensions/adapters/presenters/tui/tests/latency_test.fnl. It proves sensitivity to an injected 100 ms tick stall (including a key queued during the stall), delayed presentation, the real active poll, shared burst frames, 128-byte stream redraw batching, final flush, and responsive typing while pending text remains cached. In particular, a draft redraw alone need not expose a small delta: ingestion keeps the historical rendered row cache until the stream byte threshold or stream end invalidates it. The suite measures actual intermediate presented text, not just dirty flags or input-buffer mutation.
Reproducing TUI stalls #
make stall-check (wrapper: scripts/dev/stall-check.sh) is an opt-in harness for cooperative-yield stalls — the multi-hundred-ms gaps between coroutine yields that freeze the TUI on slow hardware. It drives the real streaming transport (fen_http.so cooperative mode), the real SSE parser, and a per-event JSON decode against a localhost SSE stream, timing the wall gap between yields (the same metric warn-if-stalled! reports). FEN_DEBUG_CHUNK_DELAY_MS (read once by fen_http.c) sleeps that many ms per drained chunk slice, so a desktop reproduces the BB10/ARM per-chunk cost; the harness prints a min/max/avg/median gap histogram and fails if any single resume exceeds FEN_STALL_BUDGET_MS (default 250). Knobs: FEN_DEBUG_CHUNK_DELAY_MS (default 15), FEN_STALL_BUDGET_MS, FEN_STALL_BODY_KB, and FEN_STALL_NICE=1 to also wrap the run in nice/taskset. FEN_DEBUG_CHUNK_DELAY_MS works against the live binary too: set it before launching fen and lower FEN_TUI_STALL_WARN_MS to make on-device stalls reproducible and loud in fen.log.
Statistical profiling #
The opt-in first-party profiler records bounded Lua VM instruction samples and separately bounded measured TUI wall gaps, then exports flame-graph artifacts without adding a metrics framework or profiler lifecycle to core. It is intended for development captures of TUI interaction, agent turns, tools, and /reload.
Quick start #
Start a function-level capture, perform the operation under investigation, then save it:
/profile start --period 50000 --mode functions
/reload
/profile save tmp/profiles/reload
save stops an active capture before serialization so the exporter cannot mutate or profile its own input. Open tmp/profiles/reload/profile.speedscope.json in Speedscope, or pass profile.folded to classic FlameGraph tooling.
When no output directory is supplied, /profile save chooses a unique timestamped directory under ${XDG_STATE_HOME:-~/.local/state}/fen/profiles/. An explicitly supplied /profile command directory is operator-controlled and used as given, so its three profile files may replace files from an earlier capture. The opt-in model-facing profile tool (see Agent access) confines a supplied output-directory to the fen profiles artifact root and rejects absolute paths outside it or any .. traversal. Omit that tool argument to use the default; the operator-controlled FEN_PROFILE_OUTPUT override may point anywhere.
Command reference #
The live command reference is /profile help (or /docs search profile); it covers start (with --period N and --mode functions|lines), mark, status, stop, report, save, and reset.
FEN_PROFILE=1 starts a capture during extension bootstrap, while FEN_PROFILE_PERIOD and FEN_PROFILE_WALL_GAP_MS configure its instruction period and wall-gap threshold. FEN_PROFILE_OUTPUT supplies the default save directory for startup/reload investigations. Starting a new capture stops and replaces an existing capture. The profiler refuses to replace an unrelated active Lua debug hook. Function mode is the lower-overhead default. Line mode can create many more distinct frames and is best reserved for short, focused captures.
On slow ARM systems, start around --period 100000 and lower the period only when more detail is needed.
Artifacts #
A save writes:
profile.speedscope.json— an interactive sampled flame graph with zero-based shared frame indexes;profile.folded— root-to-leaf folded stacks with integer sample weights;profile.json— capture configuration, limits, sample/drop counts, stable coroutine labels, measured capture wall duration and wall gaps, bounded semantic spans/counters, process CPU duration, and explicit interpretation limits.
The optional private profiler activity API records low-cardinality spans and counters without becoming a general metrics registry. TUI input and tick work are annotated as coarse spans, with their named totals available in the metadata artifact. A /reload capture also records total, core, extension, TUI, provider-refresh, session-backend, and agent-rebuild spans that correspond to the phase timings reported by /reload for #297 investigation.
Capture storage is bounded by frame, stack, depth, and retained-thread limits. A sample that cannot be represented without exceeding a frame, stack, or depth limit is dropped rather than exported with false ancestry. /profile status and profile.json report dropped samples.
Interpreting a capture #
The sampler uses Lua 5.4's instruction-count debug.sethook facility. A wider frame means that the function appeared in more Lua VM instruction samples; the width is not elapsed milliseconds. profile.json labels the sample kind and unit so downstream analysis does not accidentally present instruction counts as time.
Blocking native/C work generates no count-hook samples, including time inside libcurl, TLS, termbox presentation, subprocess waits, and filesystem calls. Qualifying TUI input and tick intervals are retained as separately labeled measured wall gaps with measured CPU time, never converted into Lua samples. Use the TUI's tui-stall diagnostics and make stall-check alongside a statistical capture when investigating responsiveness. Native host profilers such as perf remain complementary when attribution inside C libraries or the kernel is required. On supported Linux hosts, start /profile, run perf record -g -p <fen-pid> over the same marked operation, stop both captures, and correlate the shared marker time with perf report rather than treating Lua samples as native stacks.
Fen explicitly propagates the profiler hook through its cooperative coroutine constructors, so turns, reloads, compaction/handoff work, and parallel tool tasks created during a capture remain visible. Ordinary debugger or coverage hooks are not propagated, and coroutines created directly through Lua's coroutine.create retain Lua's default thread-local hook behavior. The profiler state and active hook survive /reload; reloadable command/export behavior is kept separate from that persistent state.
Agent access #
The current interface is intentionally human-controlled through /profile. The full quick workflow, commands, artifacts, interpretation, and limitations are discoverable at runtime with /docs search profile, fen_docs {topic: "search", query: "profile"}, or fen_docs {topic: "introspectors", name: "capture"}. The model-facing profile tool, which can start, mark, stop, reset, and save a capture, is a contributor-only extension; see extension enablement.
Use Nix for reproducible/binary validation:
nix build .#fen
nix flake check
make smoke-mock starts scripts/smoke/mock-openai.fnl, writes a temporary models.json, and drives scripts/dev/fen-dev --print through both OpenAI Chat Completions and Responses adapters. The mock returns a read tool call for README.md, checks that Fen executes the real read tool, then returns OK on the second model call. It also runs retry variants for both adapters: the mock returns one HTTP 500 with Retry-After: 0 for *-retry models, and the smoke fails unless the provider retries and still reaches OK.
Nix owns binary assembly; do not use generated dist/ trees as a dev loop or release artifact.
Nix result symlinks #
nix build creates result symlinks in the current directory that point into /nix/store. nix flake check or multi-output builds may leave numbered links such as result-1, result-2, etc.; cross-builds may use explicit names like result-armv7 when passed with -o. These are local build artifacts, not source files. It is safe to remove the symlinks from the repo root:
rm -f result result-* result-armv7
This does not delete the underlying store paths; nix store gc cleans unreferenced store paths later. To avoid creating links for one-off checks, use nix build .#fen --no-link or pass an explicit temporary output path with -o.
fennel scripts/docs/check-docs.fnl validates inline ;; @doc blocks. Each documented id must resolve to an export or contract entry, summaries are required, keys/kinds are checked, and duplicate ids fail fast. make check runs this before the Busted suite so generated documentation inputs stay well-formed.
fennel scripts/docs/check-links.fnl validates relative Markdown links between hand-written docs. File targets must exist, and .md#anchor / same-file #anchor targets must resolve to a heading under GitHub's slug rules. External links, *.html and docs/generated/ site-only targets, and vendored docs are skipped; make check runs it alongside the @doc check.
make graphs regenerates the tracked DOT sources, their SVG renderings, and the graph summary under docs/generated/graphs/. SVG files are intentionally generated locally rather than tracked in Git.
fennel scripts/test/fennel-check.fnl compiles every .fnl file with --globals locked to standard Lua 5.4 globals (src/) or standard + busted BDD globals (tests/). It catches syntax errors, unbalanced delimiters, and unknown identifiers (typos, missing local bindings) without executing any code. Run it after editing Fennel sources — it's faster than a full build and catches problems plain Fennel compilation can otherwise miss (bad globals become silent assignments in compiled Lua).
scripts/test/run-tests.sh runs every suite with HOME and the XDG_CONFIG_HOME, XDG_STATE_HOME, XDG_DATA_HOME, and XDG_CACHE_HOME directories pointed at a fresh temp root (FEN_TEST_HOME), removed on exit. Tests therefore never touch your real fen state, and concurrent runs in sibling worktrees do not interfere. Multi-file runs are split into small chunks that FEN_TEST_JOBS parallel Busted workers claim in turn (default: one per CPU); each worker gets its own HOME/XDG subtree under FEN_TEST_HOME. Worker output prints once all workers finish, followed by a combined run-tests: total; set FEN_TEST_JOBS=1 for a single serial Busted report (runs that pass a custom -o/--output handler in BUSTED_ARGS are serial automatically). Tests that need a real fen subprocess run scripts/test/fen-src, which boots fen.main from source through the same test bootstrap and compile cache, rather than scripts/dev/fen-dev, whose overlay recompiles every module on each start. Only the compile cache below stays shared; it is resolved from your real environment before the override.
Busted source-checkout runs install a generated-Lua cache for Fennel modules so --auto-insulate can keep resetting package.loaded between test files without recompiling the same unchanged dependency closure every time. Set FEN_TEST_COMPILE_CACHE=0 to disable it, FEN_TEST_COMPILE_CACHE_DIR to choose the cache directory, and FEN_TEST_COMPILE_CACHE_STATS to write simple hit/miss counters for benchmarking. When the default cache directory cannot be created (for example HOME=/homeless-shelter in the Nix sandbox), the run uses a cache under FEN_TEST_HOME that lasts only for that run. The cache stores compiled Lua only; each module chunk still executes in the current test VM so test isolation and module registration side effects are unchanged. Each source's compile-time dependency list is itself cached by content, so a warm run does not parse Fennel at all. The cache key fingerprints the source, Fennel version, compiler options/Lua target, statically resolvable transitive macro dependencies, and modules embedded by include or requireAsInclude. Compile-time require forms in macro modules are resolved through Fennel's macro path and fingerprinted recursively. Dynamic, missing, cyclic, eval-compiler, inline-macro, and otherwise unsupported compile-time dependencies fall back to Fennel's normal compile path rather than risk stale Lua. Unknown compiler options and option values that cannot be serialized canonically also bypass caching rather than risk reusing incompatible Lua. Clear the default cache with make test-compile-cache-clear, or remove the directory selected by FEN_TEST_COMPILE_CACHE_DIR. Set FEN_TEST_COMPILE_CACHE_STATS and compare cold/warm wall-clock runs on your own machine when evaluating the cache; absolute timings vary too much across hosts to be worth recording here.
Contributing changes #
Default to a branch and pull request for behavioral, architectural, or higher-risk changes. Small docs, test-only, or clearly behavior-preserving mechanical changes may go directly to main with explicit user approval. Use PRs for public contracts, provider transport, session persistence, dependencies, security, packaging, and releases. Independent review is encouraged for higher-risk changes; do not block on optional bot/AI review.
- Branch:
git checkout -b <type>/<slug>(e.g.refactor/...,perf/...,chore/...). - Commit focused changes and run focused tests while iterating.
- Open a PR:
gh pr create --base main. - The
prworkflow runsfennelCheckplus the Busted suite. - Run
make checkfor source/build changes before integration. - For docs/prompt-only changes, review the diff, run
git diff --check, and run relevant doc checks (make check-docsandmake check-linksfor repository Markdown). - For test-only changes, run the affected tests; broaden validation when fixtures or shared harnesses change.
- A timed-out or skipped check has not passed; report it explicitly.
- Merge with
gh pr merge <N> --squash --delete-branchafter green CI and any required human review.
gh resolves against the current repo, so run these from inside the fen checkout.
Direct push to main (explicit opt-in only) #
Push directly to main only when the user explicitly asks for it. Do not infer direct-push permission from silence, urgency, or small scope.
When the user opts in:
- Confirm the change is low-risk; use a PR otherwise unless the user explicitly authorizes an exceptional direct push.
- Run the appropriate checks above first; the post-push workflow is not a substitute.
- Review
git statusand the outgoing commits so unrelated work is not included. - Respect branch protection; do not bypass required checks or force-push.
- Push with
git push origin main. - Mention that the change shipped by direct push.
Hot reload is the development loop #
/reload is the way to iterate on this codebase. Under the canonical .#fen + scripts/dev/fen-dev workflow, edit a .fnl, type /reload from the running TUI, and keep working on the same session — the embedded Fennel compiler loads the changed source directly through FEN_DEV_PATH / FEN_EXTENSION_ROOT (as set by scripts/dev/fen-dev; equivalent --dev-path / --extension-root launcher flags remain available for ad hoc runs). Agents do not need to rebuild before telling the user a source change is ready to hot reload when the user is on scripts/dev/fen-dev.
To switch a running maintainer session to another trusted fen checkout without losing its conversation, run /reload-from /path/to/fen-worktree from this repository and it validates packages/core/src, packages/util/src, packages/fen/src, and extensions, then runs the normal forced /reload path against that worktree. This repository-local development command executes code from the selected worktree and is therefore only appropriate for trusted checkouts. There is no overlay-reset command; restart Fen to return to the base checkout.
Do not rebuild generated Lua before /reload when using scripts/dev/fen-dev. Restarting loses the TUI transcript, termbox state, the open session file, and any cached config — it should feel costly. New code is designed under the constraint "this must work under reload."
How it works #
fen.core.extensions.loader.reload owns in-process reload. The core module set is derived from package.loaded — every loaded fen.* module except fen.extensions.* (extension modules reload through their manifest's reload-modules), the persistent-identity modules (fen.main, fen.core.extensions.state), and backend selector modules matching *.backend (host injection points; reloading one in place would clobber an injected embedding backend) — so there is no hand-kept list to maintain. /reload fingerprints the active sources and skips core recompilation when none changed. When any core source changed, it clears and re-requires the complete reloadable core set so consumers that captured dependency functions are refreshed safely. Use /reload --all to force that reload even when all fingerprints are unchanged. After a successful require, reload copies the new exports onto the original module table in place and commits the new fingerprint; a compile failure keeps the last successful fingerprint so the next /reload retries it. A (local foo (require :fen.core.foo)) capture keeps the same table reference; the next foo.bar call resolves through the mutated table and lands on the new function. Module-table lookup remains the preferred hot-reload contract.
What reloads, what doesn't #
Reloadable: every loaded fen.core.*, fen.util.*, and CLI-side fen.* behavior module — picked up automatically from package.loaded. Extension modules (including the provider adapters and the JSONL session backend) are reloaded by the extension loader from their manifests' reload-modules. Bodies re-run, exports get re-pointed.
Not reloadable, identity must persist across reload:
fen.extensions.tui.state— termbox lifecycle (init flag, dimensions), the append-only transcript, scroll position, status counters, view toggles. Re-running the body would reset the live terminal.fen.core.extensions.state— the bus subscriber lists, registries (tools, commands, presenters, hooks), system-prompt fragments, loaded-extension manifests, and the active presenter ui-slot. Reloadablefen.core.extensions.*behavior modules read and write through this companion module, mirroring thefen.extensions.tui.state↔ reloadable TUI behavior split. Editing api, dispatch, prompt, presenter, or loader logic reloads cleanly; subscriptions and contributions survive because they live infen.core.extensions.state.fen.main— already on the stack.
Rules for new code #
- Keep long-running work cooperative. Anything that may touch the network, drain a subprocess, walk many files/modules, build docs, reload extensions, or otherwise take more than a quick frame should accept and call a
yield!/?yield-fncallback between chunks. The TUI drives work from coroutines; yielding is what lets it repaint, process cancel/quit keys, and show progress instead of appearing frozen. - Default to reloadable. Core/util
fen.*modules are picked up automatically frompackage.loaded; extension modules list themselves in their manifest'sreload-modules. Only persistent-identity modules (theNON-RELOADABLEset infen.core.extensions.loader.reload) and*.backendseam selectors opt out; editing a selector file needs a restart, and concretebackends.*implementations still reload normally. - Split state from behavior when callers outside the module hold references that must persist.
fen.extensions.tui.state↔ reloadable TUI behavior is the canonical example: state lives in a non-reloadable module, rendering code in a sibling that reloads against it. - Cross-module wiring resolves at call time, not capture time. Use
module.fnlookups (reload-safe), not(local fn module.fn)captured into long-lived state (pinned to the old function for the rest of the process). - Reload-side-effects must be idempotent. Reloadable modules that register things (commands, tools, fragments, event handlers) clear their prior registrations before re-registering, or every reload doubles them. First-party command extensions do this through their injected extension API at the top of its body. The external-extension loader follows the same pattern per extension.
Why this shapes the api #
Anything exported from a non-reloadable module (fen.extensions.tui.state, fen.core.extensions.state) is shape-stable — its layout is a contract that callers depend on across reload. Keep those surfaces small; iteration- prone logic does not belong there. Behavior that consumes that state (fen.core.extensions.*, TUI behavior modules) goes in sibling modules that reload against it, so the state is what's stable, the code is what's editable.
The design choices in the extension leaf modules (event bus on the state table, owner-tagged contributions, unregister-by-owner, and the command registry's lookup-and-pcall path) fall out of this split: subscriptions and registries live in fen.core.extensions.state, registry/event behavior lives behind reloadable module tables, and the loader-owned api factory lives in fen.core.extensions.loader.api. The api factory wraps its method references in closures that resolve through the registry/event module tables at call time, so an api held past a reload picks up the new behavior rather than pinning the old.