Extensions

fen has a small Lua/Fennel extension system for adding behavior around the core agent loop without patching packages/core/ directly. Extensions can add slash commands, tools, hooks, system-prompt fragments, event subscribers, and presenters.

The extension system is intentionally a soft plugin boundary: extensions run inside the same Lua VM as the agent and have normal host access. Install only extensions you trust.

Mental model

There are three pieces:

Mental model table
piecepathrole
Extension API / registriespackages/core/src/fen/core/extensions/Event bus, command/tool/presenter registries, UI slot, lifecycle dispatch
Extension loaderpackages/core/src/fen/core/extensions/loader/Walks roots for extension entries, checks deps, loads/reloads each spec
Extension sourceextensions/adapters/ and extensions/behaviors/ (first-party) or user config dirsContributes behavior by calling the API

An extension is usually a directory with an entry and an optional manifest:

my-extension/
  manifest.fnl   # optional metadata for the loader
  init.fnl       # runtime registration entrypoint (default; manifest can override)

Short version:

manifest.fnl = how to load/reload/describe the extension (optional)
init.fnl     = what the extension actually does once loaded

The fen.extensions.* namespace is a convention for first-party rocks, not a structural requirement. Third-party extensions may pick any namespace, and project-local drop-ins need no namespace at all — the manifest or local entry file decides.

First-party source is organized by role, not by register kind:

extensions/
  adapters/     # providers, presenters, session storage backends
  behaviors/    # commands, tools, prompt fragments, panels, controls, hooks

Within behaviors/, actions/ contains command-oriented workflows such as conversation/session lifecycle commands. These are not session adapters: the adapter is the storage backend under adapters/session-backends/, while behaviors/actions/sessions/ is the frontend behavior that calls whichever backend is active.

Adapters connect fen to a substrate or backend. Behaviors add agent/user-facing capabilities and may register any mix of commands, panels, tools, status items, prompt fragments, controls, and hooks. For example, behaviors/inspectors/queue owns /queue, /cancel-all, panel rendering, and persistent panel state as one cohesive behavior; the queue data itself lives in the steering queue service it renders.

Discovery

Extensions are discovered by walking known roots for extension directories and single-file entries. Internal first-party extensions are loaded from the embedded manifest registry. First-party overlay roots are walked recursively so the in-repo adapter/behavior taxonomy can be used during source-checkout development. Project and user drop-in roots remain shallow and flat.

Roots, in priority order (first match wins per name):

  1. Explicit --extension <path> flags — single extension dir or single file
  2. Trusted first-party flat overlays — --extension-root / FEN_EXTENSION_ROOT, used by the single-file launcher for source-checkout first-party extension development
  3. Project-local drop-ins — .fen/extensions/ in the current directory and each ancestor up to the first .git/.hg worktree marker (or filesystem root if no marker exists)
  4. User config drop-ins — $FEN_EXTENSIONS_PATH (colon-separated explicit roots) and ${XDG_CONFIG_HOME:-~/.config}/fen/extensions/
  5. Internal first-party extensions — known embedded manifest modules from the runtime ZIP/module searchers

Project-local fen/extensions/ is intentionally not an implicit filesystem root. Use .fen/extensions/ for project drop-ins, ${XDG_CONFIG_HOME:-~/.config}/fen/extensions/ for user-global drop-ins, or name another root explicitly.

For project and user drop-ins, discovery is shallow: only direct children of a root are considered. A candidate may be either:

First-party extensions are enabled unless their manifest sets :enabled-by-default false. The structured todo companion is off by default; enable it with /extensions enable todo or "extensions": {"todo": {"enabled": true}} in settings. This restores todo_write, /todos, and its status/panel views. Plan and goal remain enabled by default. Project-local extensions are enabled by default even without :enabled-by-default true, because placing an extension under a project's own .fen/extensions/ is treated as intent to run it. User-global discovered extensions still honor :enabled-by-default. Explicit --extension <path> always loads regardless of that field and wins name dedupe over a disabled first-party spec.

The extensions object in ~/.config/fen/settings.json holds per-extension settings keyed by extension name, inheriting from optional defaults.json:

{
  "extensions": {
    "decide": {"enabled": true, "model": "~typesafe/jev-latest"}
  }
}

A boolean enabled overrides the manifest's :enabled-by-default and the project-local rule for every source except explicit --extension, which still always loads. An absent enabled keeps the rules above. Changes take effect on /reload or restart; a disabled extension's contributions are removed through the same owner cleanup reload uses. /extensions enable <name> and /extensions disable <name> write that flag, preserving every other settings key, and then run /reload. They accept listed extensions, including discovered-but-disabled ones, and reject unknown names. /extensions disable refuses the active presenter and the current owners of /reload and /extensions, to avoid breaking the running UI or its recovery commands. To override this guard deliberately, edit settings.json directly and reload or restart; settings-based disablement remains unrestricted. The model-facing extension tool cannot change it: enabling code stays a user decision. /extensions marks a status decided by settings rather than the manifest with (settings). The remaining keys are the extension's own settings, read with api.settings.extension. An explicit extension directory inside a trusted first-party overlay root keeps first-party trust, so it may register privileged kinds. From a source checkout (scripts/dev/fen-dev or scripts/test/fen-src), enable the test-only mock provider with --extension extensions/adapters/providers/mock --provider mock. Enable the contributor-only model-facing profile tool with --extension extensions/behaviors/inspectors/profiler-tool; the /profile command is on by default. The fen source checkout's .fen/extensions/fennel-eval/ development escape hatch remains inert unless FEN_FENNEL_EVAL=1 is set.

In the canonical source-checkout workflow, scripts/dev/fen-dev prepends extensions to FEN_EXTENSION_ROOT for the single-file runtime. First-party flat extensions are loaded directly from .fnl source under the taxonomy directories; no rebuild or dist/ mirror is required for reload-driven development.

Hidden and underscored entries in project-local roots are skipped silently:

.foo/       # skipped
_foo/       # skipped
.foo.fnl    # skipped
_foo.lua    # skipped
foo/        # considered
foo.fnl     # considered

If both foo/ and foo.fnl/foo.lua exist in the same root, the directory wins. Duplicate names across roots are first-seen-wins; later duplicates are skipped silently.

Manifest

Directory extensions may declare a manifest.fnl (or manifest.lua) at their root. A Fennel manifest is just a table:

{:name :hello
 :description "Example extension"
 :enabled-by-default true
 :entry-module :fen.extensions.hello
 :requires-modules [:lfs]
 :reload-modules [:fen.extensions.hello.helper
                  :fen.extensions.hello]
 :reload-exclude [:fen.extensions.hello.state]}

Fields:

Manifest table
fieldmeaning
:nameExtension owner/name used for introspection and teardown. Falls back to dir name.
:descriptionHuman-readable description.
:enabled-by-defaultWhether discovered extensions load automatically; see Discovery for per-source rules. Explicit --extension always loads.
:entry-moduleLua module name resolved through require. The module should return a register function, or a table with :register. Used by first-party extensions and rock-shaped installs.
:entryFile path relative to the manifest dir. The file is dofile'd and should return a register function, or a table with :register. Used by path-shaped (project drop-ins, single-file).
:requires-modulesLua modules probed with require before loading; missing ones fail the load with an install hint (see Packaging and dependencies).
:requires-shared-libsSystem libraries named in missing-module diagnostics only.
:reload-modulesModule names to clear from package.loaded on reload.
:reload-excludeModule names to preserve even if listed or otherwise known. Use for persistent state.
:interactive-only?Load only in the interactive (TUI) load pass, not the early non-interactive pass that headless runs and provider resolution use.
:presenterLoad only when this presenter is active (for example :tui); omit to load under any presenter.

If no manifest is present, the loader uses the directory basename as the extension name and falls back to <dir>/init.{fnl,lua}. If a manifest is present but neither :entry-module nor :entry is set, the same init fallback is used.

The loader records disabled, missing-dependency, loaded, and error states for /extensions and api.list :extensions.

Discovery and reload code may receive an optional cooperative yield callback from the presenter runtime. Long filesystem walks, manifest scans, and subprocess drains should call it between chunks or directory entries. The callback may raise for cancellation, so discovery helpers must close pipes and files before rethrowing.

Entrypoint shape

One entrypoint shape is preferred: the loaded entry returns a register function that receives api. A manifest can point at either a file (:entry) or a module (:entry-module); without a manifest, the init.{fnl,lua} file fallback is used. The loader creates the API, removes prior owner-tagged contributions, and invokes the register function, so normal extensions do not need to call unregister-by-owner themselves:

(fn [api]
  (api.register :command
                {:name :hello
                 :description "Say hello"
                 :handler (fn [_args _ctx]
                            (api.emit {:type :info :text "hello"}))}))

A table with :register is also honored and is useful when a module wants to expose helpers for tests:

{:register
 (fn [api]
   (api.register :command
                 {:name :hello
                  :handler (fn [_args _ctx]
                             (api.emit {:type :info :text "hello"}))}))}

Lua extensions work too:

return function(api)
  api.register("command", {
    name = "hello",
    description = "Say hello",
    handler = function(args, ctx)
      api.emit({ type = "info", text = "hello" })
    end,
  })
end

File-backed extensions can load sibling files via (api.load :state) — resolved relative to the manifest dir, no namespace required:

(fn [api]
  (let [state (api.load :state)]   ; loads ./state.fnl or ./state.lua
    (api.register :command
                  {:name :hello :handler (fn [] (state.greet))})))

An entry that returns neither a register function nor a table with :register fails to load. API construction is loader-owned; extensions never construct an api directly.

API surface

The API table passed to an extension contains:

API surface table
method / fieldpurpose
api.register(kind, spec)Register public contribution kinds: tools, commands, controls, hooks, status items, or panels.
api.on(event-name, handler)Subscribe to event bus events. :* receives all events.
api.emit(event-table)Publish an event.
api.log(level, message-or-table)Persist an owner-tagged diagnostic record and mirror it to stderr when FEN_LOG allows the level.
api.prompt(text-or-fn, opts)Add system-prompt fragments.
api.list(kind)Frozen introspection lists.
api.introspectIntrospection helpers: collect.
api.actionsPrivileged harness/headless action helpers: list, invoke.
api.commandsCommand helpers: dispatch.
api.turnTurn helpers: submit!.
api.enqueue(kind, text, opts?)Queue a :steering or :follow-up message through the interactive runtime.
api.authAuth backend helpers: find-backend.
api.sessionActive session helpers: active-backend, set-info!, info, append-state!, latest-state.
api.diagnosticsDiagnostic helpers: list-errors, error-log-path.
api.settingsSettings proxy: set-defaults!, set-thinking-default!, and extension, which returns this extension's extensions.<name> settings (without enabled) or {}. It re-reads defaults.json and settings.json on each call, so it follows /reload; apply your own defaults.
api.modelsModel registry proxy: list, resolve, canonical-id.
api.uiActive presenter UI slot helpers: has-ui?, notify, prompt, select. See UI helper fallback behavior.
api.load(name)File-backed extensions only: load <manifest-dir>/<name>.{fnl,lua} and return its value. Use for sibling files without a namespace.

For third-party extensions, this API table is the compatibility contract. Raw fen.core.* requires are private implementation details unless explicitly surfaced above. First-party in-tree extensions may still require internals when there is no public equivalent, but should prefer api as the boundary.

api.register has a public and privileged kind split. Public extensions may register :command, :tool, :hook, :input-handler, :status, :panel, :control, :introspect, and :action. Infrastructure kinds :provider, :auth-backend, :session-backend, and :presenter are reserved for first-party extensions (embedded, trusted overlay roots, or explicit directories inside those roots) until fen has an explicit third-party trust/capability model.

Capability taxonomy

The table above lists every method; this groups them by what they do and which tier sees them today. Capability category — not namespace — is the natural axis for any future public/privileged split: contribution and read-only introspection are safe for any extension, while mutation and infrastructure install are tier-sensitive.

Capability taxonomy table
capabilitymethodstier today
Contributeregister (9 public kinds), prompt, on/emitbase
Host controlactions.list, actions.invokeprivileged first-party extensions and test/harness API
Contribute (infrastructure)register (:provider, :auth-backend, :session-backend, :presenter)privileged (first-party)
Introspect (read-only)list (14 kinds), introspect.collect, models.*, diagnostics.*, session.info/active-backend, auth.find-backend, settings.extensionbase
Mutatesettings.set-defaults!, settings.set-thinking-default!, session.set-info!, session.append-state!base
Driveturn.submit!, enqueue, commands.dispatchbase
UIui.has-ui?/notify/prompt/selectbase

The surface is 15 namespaces and 27 leaf methods, with register fanning out to 13 contribution kinds and list to 15 extension-facing introspection kinds. Only the four infrastructure register kinds are tier-gated today; the Mutate methods are currently exposed to every extension regardless of source.

Registering typed actions

Actions are a narrow typed host-control contract for harnesses and headless runtimes rather than model-facing tools. Every extension may register an owner-scoped action, and the loader supplies that owner from the extension manifest or entry name.

(api.register :action
              {:name :stop
               :description "Stop the active bounded goal run"
               :parameters {:type :object :properties {}}
               :invoke (fn [args ctx]
                         (stop-goal! api ctx))})

Action names are scoped by owner, so a privileged caller invokes :stop from the :goal extension as (api.actions.invoke :goal :stop {} ctx). api.actions.list returns frozen descriptors with :owner, :name, :description, and :parameters but never the invoke callback. api.actions.invoke validates arguments with fen.util.json_schema and returns either {:ok true :owner ... :action ... :state ...} or {:ok false :owner ... :action ... :error ... :state ... :details ...} when a domain rejection supplies its state snapshot. Unknown owners or action names and schema-invalid arguments are structured failures rather than thrown command text.

Actions are not tools, do not appear in tool descriptors, do not enter tool transcripts, and do not pass through model tool policy. Base (third-party) extensions may register actions but cannot list or invoke them. Privileged means the loader assigned source :first-party, so embedded first-party extensions receive api.actions.list and api.actions.invoke alongside the test/harness API. There are no in-tree production consumers yet; this seam exists for #175/#252-style headless harnesses. Issue #181 will formalize finer-grained capability tiers beyond this initial authority seam.

Keep one extension-owned domain transition behind every frontend adapter. For example, a command, model-facing tool, and privileged action may each adapt arguments and presentation around the same replace-items! or stop-goal! transition. The goal companion exposes stop, resume, and clear actions but defers start pending a typed fresh-objective/run-context contract.

Registering commands

(api.register :command
              {:name :hello
               :description "Say hello"
               :idle-only? false
               :handler (fn [args ctx]
                          (api.emit {:type :assistant-text
                                     :text (.. "hello " args)}))})

Command handlers receive:

Commands with subcommands can use fen.util.subcommands to avoid ad-hoc argument parsing. The helper builds trim/lowercase dispatch, generated /cmd help, unknown-argument feedback, :usage, and a presenter-agnostic :subcommands descriptor that command completion can consume:

(local subcommands (require :fen.util.subcommands))

(let [sub (subcommands.build
            {:name :mem
             :emit api.emit
             :summary "Memory diagnostics panel"
             :default (fn [_args ctx] (toggle-panel ctx))
             :subcommands
               {:gc {:description "force a GC pass"
                     :handler (fn [_args ctx] (run-gc ctx))}
                :off {:description "hide the panel"
                      :handler (fn [_args ctx] (hide-panel ctx))}}})]
  (api.register :command
                {:name :mem
                 :description "Memory diagnostics panel"
                 :usage sub.usage
                 :subcommands sub.descriptor
                 :handler sub.handler
                 :complete sub.complete}))

Subcommand handlers receive the trimmed argument string after the subcommand word plus the command context. A bare invocation calls :default when present, while /cmd help renders the generated table unless a real :help subcommand is declared. An unknown subcommand emits an error plus that help table; set :default-takes-args? true only for commands whose default accepts arbitrary free-form arguments.

Argument completion

A command may add an optional :complete function to supply argument completions to the TUI's inline completion menu. Alternatively, a command may set :subcommands to a descriptor from fen.util.subcommands; the registry uses it as a completion fallback when no :complete hook is present. The menu filters as you type and opens above the input while editing a /command:

(api.register :command
              {:name :skills
               :handler (fn [args ctx] ...)
               :complete (fn [arg-prefix ctx]
                           [{:label "redact-logs" :value "redact-logs"
                             :description "Redact sensitive strings"}])})

:complete receives the current argument word (arg-prefix) and the run context. It returns a list of choices shaped {:label str :value any :description str?} — the same shape api.ui.select consumes. :value is the token spliced into the input on commit. :label and :description are shown in the menu. The menu filters the returned choices by the typed word, so a completer may return its full candidate set. Errors in :complete are isolated and never crash input. Return [] or nothing for "no completions".

Commands that need to start a normal agent turn should use the small turn helper instead of mutating ctx.turn or ctx.busy? directly:

(let [result (api.turn.submit! ctx "Execute the approved plan now." {:when-busy :reject})]
  (when (not result.ok)
    (api.emit {:type :error :error result.error})))

api.turn.submit! accepts the command context, the user text to submit, and optional busy/display behavior. Calls through this API echo the submitted user text to the live transcript by default. Pass {:emit-user? false} to suppress that when an extension has already displayed equivalent text.

When idle, it starts the same normal user-turn path used by presenter input and returns {:ok true :started true}. Empty text returns {:ok false :error "cannot submit an empty user turn"}. Unknown :when-busy modes return {:ok false :error "invalid when-busy mode: ..."}.

Enqueueing steering and follow-ups

Use api.enqueue when an extension needs the same steering or follow-up queue that interactive user input uses. This is an interactive-session-only API: without a live interactive runtime it returns {:ok false :error "no interactive runtime"}. The method accepts exactly :steering or :follow-up, a non-empty text string, and optional options; invalid text returns a structured {:ok false :error ...} result. Queued messages preserve FIFO ordering with user-entered messages, update presenter queue counters, and are cleared by /cancel-all or /queue clear like user-entered entries.

(api.enqueue :steering "Use the new test result before continuing.")
(api.enqueue :follow-up "Review the completed background task.")

Follow-ups normally wait for the ordinary agent queue callback, including when queued while idle. Pass {:start-if-idle? true} for a follow-up from an idle event handler when it should start a normal turn at the next safe runtime tick. The option never starts a turn reentrantly and has no effect while a turn is active. The API does not impose caps or rate limits; extensions own that policy. In particular, a turn-completion handler that enqueues {:start-if-idle? true} on every completion creates an unbounded loop, and /cancel-all cannot durably stop it while that handler remains active; callers must supply caps and termination policy.

Registering tools

Tool specs match the built-in AgentTool shape handled by packages/core/src/fen/core/tools.fnl. Tools may set :exposure :search to remain discoverable through tool_search; their schemas become provider-visible after activation while execution remains registered throughout the conversation. Omitting :exposure preserves the existing immediately visible behavior:

(api.register :tool
              {:name :greet
               :label "greet"
               :description "Greet someone"
               :parameters {:type :object
                            :properties {:name {:type :string}}
                            :required [:name]}
               :execute (fn [args _ctx]
                          {:content [{:type :text
                                      :text (.. "hello " args.name)}]})})

Built-in background subagents

The built-in subagent tool remains blocking by default. Set background: true explicitly to launch a detached child and return immediately with its run ID. Detached children are pumped cooperatively from presenter ticks, and their progress appears in the existing /subagents and status surfaces. Background mode currently requires the TUI presenter; one-shot and blocking-input presenters reject it because they cannot provide idle ticks. Reloading the subagent extension cancels and reaps active background children.

A completed background job queues a compact follow-up for the parent agent but does not start a turn automatically. Use /subagents show RUN_ID to inspect its stored result and diagnostics. Pass collect: "full" to queue the complete final text instead of the default summary. Use /subagents steer RUN_ID NOTE to steer a running child with additional context, and /subagents cancel RUN_ID to stop it.

At most four blocking or background subagent runs may be active together. Launches at the limit are rejected rather than placed in another queue. Background jobs are read-only; the parent agent owns repository edits. Normal quit, cancellation, and timeout clean up child process groups. Containment is process-group scoped: a descendant that escapes the group (for example by calling setsid()) can survive a timeout or cancellation, so whole-tree termination is not guaranteed without the optional sandbox. Cleanup after an uncatchable parent crash or SIGKILL is best-effort and is not guaranteed.

Background run records and process handles survive /reload, while public introspection exposes only sanitized run data.

Hooks

Extensions may register a pre-tool-call policy hook that runs before tool argument validation and execution for both built-in and extension tools.

(api.register :hook
              {:before-tool
               (fn [ctx]
                 (when (and (= ctx.name :bash)
                            (string.find (or ctx.arguments.cmd "") "rm %-rf"))
                   {:block true :reason "dangerous command"}))})

The hook receives {:name :arguments :tool :cwd :source} and returns nil or {:allow true} to allow a call, or {:block true :reason "..."} to block it. ctx.arguments is a shallow copy of the submitted arguments, and policy hooks cannot rewrite the arguments used for execution; treat the table and any nested values as read-only. Hooks run in registration order, the first block wins, and later hooks are not run after a block. A throwing before-tool hook fails closed, blocks the call with a policy hook failed reason, and is recorded in extension diagnostics. This is an intentional behavior change from the old fail-open pcall; purely observational consumers should use the events bus instead of a policy hook. Blocked calls are normal structured tool errors visible to the model and user. Confirmation is intentionally not part of this Phase 1 API and is planned for a future Phase 2.

System prompt fragments

Prefer the ergonomic api.prompt helper:

(api.prompt
  "Extra instruction from my extension."
  {:id :my-extra-instruction
   :title "My extra instruction"
   :description "Short inspection text"
   :order 90})

Fragments are sorted by numeric :order and registration sequence, then joined with blank lines. The first argument to api.prompt may also be a function evaluated with the prompt render context when the system prompt is built. Failures degrade to an HTML comment instead of crashing prompt assembly.

To inspect prompt size without dumping the prompt text, run /prompt stats in the TUI. It re-renders each registered fragment against the live session opts/tools and reports per-fragment byte length and an approximate token count, plus a total, so you can see which fragments dominate the system prompt. Because it renders fresh, volatile fragments (date, cwd) reflect the current moment rather than the value baked into the prompt at agent construction.

Extension logs

Call api.log for owner-tagged operational diagnostics that must survive stderr scrollback and extension reloads. Each call enters the shared bounded in-memory ring (500 records), appends a sanitized JSON record to ~/.local/state/fen/logs.jsonl, and mirrors through fen.util.log when the configured FEN_LOG level allows it. Records have named owner, level, timestamp, msg, and optional session fields, and table payloads are JSON-rendered with secret-like fields redacted. Use api.list :logs or (:get :extensions :logs) through agent_state for structured inspection by owner or level. The JSONL file does not rotate in v1, so operators should monitor its disk usage.

(api.log :info "wrote 12 messages to session.jsonl")
(api.log :debug {:retry-count 3 :reason :rate-limit})

Event bus

Subscribe to one event:

(api.on :tool-call
        (fn [ev]
          (api.emit {:type :info
                     :text (.. "tool: " (tostring ev.name))})))

Subscribe to all events:

(api.on :* (fn [ev] ...))

Handlers are pcall isolated so one failing extension does not stop sibling handlers.

Common event types include:

Custom event types should be namespaced by convention, e.g. :git-checkpoint/snapshot-created.

Registering introspection snapshots

An :introspect contribution exposes a sanitized, read-only snapshot of extension-owned state. Snapshots are collected on demand by agent_state, /extensions <name>, and future runtime diagnostics.

(api.register :introspect
              {:name :panel
               :description "Current panel state"
               :snapshot (fn [_ctx]
                           {:visible? state.visible?
                            :selected-name state.selected-name})})

Names are owner-scoped: two extensions may both register :summary. Use api.list :introspectors to inspect descriptors without executing snapshot thunks. Use api.introspect.collect to collect owner-scoped outputs:

(api.introspect.collect)         ; all owners
(api.introspect.collect :my-ext) ; one owner

Snapshots must be cheap, side-effect-free, non-blocking, and must not expose secrets. Return plain JSON-friendly data: strings, numbers, booleans, nil, arrays, and tables. Avoid functions, userdata, threads, and cycles. Snapshot failures are isolated and surface as {:error "..."} without preventing other snapshots from being collected. For hot reload, snapshot thunks should resolve behavior and persistent state at call time rather than capturing stale function locals.

Presenters

A presenter is an interactive host such as the built-in TUI, a future REPL, or an RPC server. Only one active presenter owns the UI slot.

(api.register :presenter
              {:name :my-presenter
               :active? true
               :init (fn [ctx] ...)
               :run (fn [ctx] ...)
               :shutdown (fn [ctx] ...)
               :ui {:notify (fn [text opts] ...)
                    :prompt (fn [opts] ...)
                    :select (fn [opts] ...)}})

main.fnl calls presenter lifecycle through core.extensions:

init-active-presenter → run-active-presenter → shutdown-active-presenter

The built-in TUI is a first-party extension under extensions/adapters/presenters/tui/. For how its regions, status items, panels, and controls fit together, see the TUI design guide.

api.ui public surface

api.ui is the only extension-facing UI namespace; it is not going away or flattening to top-level api.select/api.notify helpers. Its intended public surface is exactly four methods:

Selector choices have the shape {:label str :value any :description str?}. Search-capable presenters initialize their filtering and ranking from opts.initial-query, while presenters without interactive filtering may ignore it.

Everything inside fen.core.extensions.register.presenter beyond build-ui-slot (slot promotion, presenter registration/lookup, lifecycle dispatch) is core runtime plumbing, not part of this contract, and may change without notice.

UI helper fallback behavior

api.ui methods dispatch to the active presenter's :ui table when one is registered. When no presenter is active, each method falls back to explicit, non-blocking behavior instead of touching stdin:

Extensions that need interactive input should call api.ui.has-ui?() first and treat a nil result from prompt/select as "no UI available" rather than "the user answered nothing."

Registering status items

A :status is one block in the presenter's status bar, in the Waybar/Polybar style: each item renders independently and the presenter composes them. First-party status items in the TUI surface the model name, context tokens, queue counts, and scroll offset.

(api.register :status
              {:name :git-branch
               :side :right
               :order 10
               :render (fn [_ctx]
                         {:text (current-branch) :style :dim})})
Registering status items table
fieldmeaning
:nameIdentifier (required). Owner-scoped; two extensions may share a name.
:render(fn [ctx] {:text str :style style-keyword}) (required). Return nil to hide for this frame.
:side:left or :right. Defaults to :left.
:orderNumber; lower = closer to the side anchor. Defaults to 50.

Sort is ascending by :order, then owner, then name. The right group is right-aligned; the left group is left-aligned. :render is invoked every frame and must be cheap and side-effect-free.

Registering panels

A :panel is a bounded vertical region above the input or below the status bar. It owns a row count per frame and a list of rows to paint; the presenter handles geometry, clipping, and clamping to available space. The TUI's busy spinner is the smallest first-party example (extensions/adapters/presenters/tui/panels/busy.fnl).

(api.register :panel
              {:name :recent-commits
               :placement :above-input
               :order 20
               :height (fn [_ctx] 3)
               :render (fn [_ctx]
                         [{:text (.. "HEAD: " (head-summary)) :style :dim}
                          {:text (.. "+ "    (changed-files)) :style :dim}
                          {:text "" :style :dim}])})
Registering panels table
fieldmeaning
:nameIdentifier (required).
:height(fn [ctx] <int>) (required). Non-negative; 0 hides the panel for this frame.
:render(fn [ctx] [<row>...]) (required). Each row is {:text str ?:style style-keyword ?:segments [...]}. An empty list is equivalent to height 0.
:placement:below-status or :above-input. Defaults to :above-input.
:orderNumber; lower = closer to the placement anchor. Defaults to 50.

:below-status panels stack downward from the status row; lower :order is closer to the top. :above-input panels stack upward from the input row; lower :order is closer to the input. The presenter walks panels per-frame inside pcall, so one extension's render error becomes a single panel-error:<name> row instead of crashing the frame.

:render and :height run on every frame. Keep both cheap; do expensive work in cooperative event handlers and cache the result in extension state.

Semantic styles

:status items and :panel rows declare colors with semantic keywords; presenters own the actual color tables.

Semantic styles table
keywordtypical use
:userUser-authored content / accent
:assistantAssistant content / section headings
:toolTool-call labels
:errorErrors
:dimSecondary or muted text
:statusDefault for status items (TUI: reverse-video bar fg)

Unknown styles fall through to a presenter default; new styles will be added as the theme system matures, so :render should not depend on the exact rendering of any one keyword.

Context compaction

The first-party compact extension (extensions/behaviors/companions/compact/) exposes both /compact [guidance] and an agent-callable compact tool. Both paths summarize older messages, keep recent messages verbatim, append a durable :compaction session entry, and replace the active model context only after persistence succeeds. The tool accepts optional guidance describing facts, files, progress, or next steps that its summary must preserve. It should be called only when substantial older context can be discarded, not repeatedly on short sessions. Agent-triggered compactions are recorded with :trigger :agent; manual commands retain :trigger :manual; auto-compactions use :trigger :auto.

Auto-compaction

Auto-compaction is off by default. Enable it with a positive autoCompactTokens under extensions.compact in settings.json, e.g. "extensions": {"compact": {"autoCompactTokens": 150000}}. Absent or non-positive, fen only compacts on /compact or the tool. The threshold is compared against the same approximate context-token estimate that /compact reports as tokens-before; fen does not derive it from the model's context window.

Each completed turn is evaluated once, on the next runtime tick, and only while the runtime is idle. A turn that starts right away, such as a goal iteration or a queued follow-up, skips that evaluation, as does a cancelled turn. Presenters without runtime ticks (--print, json) never auto-compact. Evaluation skips silently when the session backend cannot persist compactions or there is nothing to compact.

At or above the threshold, fen compacts. Only the experimental decide extension, when enabled, can compact earlier, from 80% of the threshold.

Auto-compactions run like /compact, can be cancelled the same way, and are recorded with :trigger :auto. A failed auto-compaction reports one error and is not retried until another turn completes.

Goal companion

The first-party goal extension (extensions/behaviors/companions/goal/) adds /goal for bounded autonomous objective execution. It is the interactive MVP for the goal-orchestration roadmap: a user gives fen an objective, fen works through normal model turns, and the extension stops the run when the model reports completion/blockage/error or when the iteration cap is reached. The MVP composes existing primitives instead of adding a second agent loop. The submitted goal prompt tells the model to draft or reuse a short plan, use subagent for self-contained scout/plan/review work when helpful, run appropriate checks, and end every iteration with an explicit GOAL_STATUS: ... marker.

Subcommands

Subcommands table
commandeffect
/goal <objective>Start a bounded goal run with the default iteration cap.
/goal --max-iterations N <objective>Start with an explicit cap.
/goal statusPrint the current goal state.
/goal stopStop future autonomous iterations.
/goal resumeExplicitly resume a blocked, stopped, or errored goal if it is still under its cap.
`/goal panel on\off`Show or hide the goal panel; bare /goal panel toggles it.
/goal clearClear the in-memory goal state.

The visible default cap is 10 iterations, and explicit caps cannot exceed 100, so a goal run has substantially more CLI headroom without becoming unbounded by default. /goal resume rejects completed, cap-reached, idle, and already-running goals rather than guessing that another iteration is wanted. /goal stop marks and persists the run as stopped before requesting cooperative cancellation of an active goal turn, so its completion cannot schedule another autonomous iteration or revive after restart. Goal continuations are submitted directly instead of entering the shared follow-up queue, so stopping a goal leaves user-owned steering and follow-up entries untouched.

Iteration lifecycle

A goal run starts by submitting a hidden user turn through api.turn.submit!. The extension records running, iteration-count, max-iterations, the objective, and the latest model result in its non-reloadable state module (extensions/behaviors/companions/goal/state.fnl). Each authoritative transition is also appended to the current JSONL session through the generic extension-state API. On startup with --continue or after /resume, the latest valid goal entry for that exact session is restored. A persisted running entry is treated as an interrupted iteration: fen reports it as blocked and resumable, and waits for an explicit /goal resume rather than autonomously submitting work. /new switches to an empty session and clears the in-process goal view, while /reload keeps the non-reloadable state table and does not replace it with state from another session. When :agent-turn-complete fires, the extension reads the final GOAL_STATUS marker:

Iteration lifecycle table
markereffect
GOAL_STATUS: continueStart the next iteration if the cap has not been reached.
GOAL_STATUS: doneMark the goal done.
GOAL_STATUS: blockedMark the goal blocked and leave recovery to the user.
GOAL_STATUS: errorMark the goal errored.

If the marker is missing, the run is marked blocked instead of guessing whether to continue. If the marker asks to continue at the cap, the run is marked cap-reached and no further turn is submitted. A user stop or cancelled turn is stopped, model completion is done, a recoverable wait is blocked, an unexpected failure is error, and exhausted autonomy is cap-reached. Each start, manual resume, automatic continuation, and stop decision emits a visible :info event with structured :source :goal, :decision, :status, :reason, :iteration, and :max-iterations fields for audit-oriented subscribers.

Status, panel, and context budget

While a goal has visible state, the extension contributes a left-side status item such as goal:1/3 or goal:done. The optional panel shows the objective, iteration count, last reason, and a short preview of the latest result. The extension also exposes an introspection snapshot for /extensions goal and agent_state.

Before scheduling a follow-up iteration, /goal checks the same rough context-token estimator used by the status line. When the conservative high-context threshold is reached and the compact tool is available, the next iteration is required to call compact before doing more work. The goal blocks rather than continuing blindly if the tool is unavailable, the call fails, or the iteration finishes without a successful agent-triggered :compaction-summary event. Provider context-limit failures are reported as blocked with /compact and /goal resume recovery guidance instead of being collapsed into generic runtime errors. Autonomous continuation does not change tool policy: destructive or external actions still use the normal policy and confirmation surfaces.

Plan companion

The first-party plan extension (extensions/behaviors/companions/plan/) adds /plan for drafting, revising, inspecting, and approving execution plans. While a plan or revision turn is running, its before-tool hook keeps the model in a read-only lane by allowing only read, grep, find, ls, agent_state, and fen_docs. Mutating tools such as bash, edit, and write are blocked until the user approves the captured plan. See tools.md for what each of these tools does.

Subcommands

/plan dispatches on its first argument; anything it does not recognize as a subcommand is treated as a new planning request.

Subcommands table
commandeffect
/planPrint usage.
/plan <request>Draft a read-only plan for <request>, entering plan mode.
/plan revise <notes>Revise the captured plan using <notes> as guidance, re-entering plan mode.
/plan approveExecute the captured plan as a normal user turn.
/plan showPrint the captured plan (or a hint if none exists).
/plan cancelLeave plan mode and clear the captured plan.
`/plan panel on\off`Show or hide the plan panel; bare /plan panel toggles it.

revise and approve require a captured plan; without one they emit an error and do nothing. Subcommand names are matched case-insensitively.

Mode lifecycle

The extension tracks a small mode machine in its non-reloadable state module (extensions/behaviors/companions/plan/state.fnl):

idle ──/plan <request>──► planning ──turn ok──► ready
                              ▲                   │
                              │                   ├──/plan approve──► idle
                         /plan revise             │
                              │                   │
                          revising ◄──────────────┘
                              │
                          turn ok──► ready

planning and revising are the two active read-only states (planning? is true in both), so the before-tool allowlist applies during a revision turn as well. When a plan or revision turn completes successfully, on-turn-complete captures the result as last-plan and moves to ready. ready means a plan has been captured and the model is no longer in the read-only lane; the extension is idle and waiting for the user to approve, revise, or cancel. approve flips the mode back to idle before submitting, so the execution turn runs with normal tools available. cancel, a cancelled turn, a failed turn, or an :error/:reset-conversation event all return the mode to idle.

Status and panel

While a plan is active (any mode other than idle), the extension contributes UI state:

The panel only renders when it is both active and visible; /plan panel toggles the visible flag, and /plan panel on|off sets it explicitly.

Approving a plan

/plan approve submits the approved plan through api.turn.submit! as a normal user turn:

Approved plan:

<plan text>

Execute this plan now.

That keeps turn/coroutine ownership inside the runtime instead of having the extension mutate presenter state directly.

> The read-only allowlist (READ_ONLY_TOOLS at the top of init.fnl) is hand-maintained. > Tools carry no read-only/mutating metadata to derive it from, so the list is an explicit allowlist that fails safe: any tool not named there — including newly added inspection tools — is blocked while planning. > Keep it in sync as new read-only tools land, or they will be unusable in plan mode.

Subagents

The first-party subagent extension (extensions/behaviors/companions/subagent/) registers a subagent tool that delegates a focused task to a live child fen process with its own context window, a dedicated system prompt, and explicit provider/model routing. The child's persona comes from either a discovered named agent file or an inline prompt argument, so an agent file is convenient but not required (see "Tool" below). By default the child inherits the parent agent's provider and model when the subagent tool context exposes them. For agentic launches, first call the tool with action: "models"; this refreshes catalogs, filters out credential-gated providers that are not authenticated, omits fallback IDs when a dynamic catalog cannot be verified, and returns exact provider/model routing pairs. The caller should match one pair, pass both fields explicitly, and provide a self-contained task with cwd, expected artifact, and short bounds. After failure, inspect or steer the retained run instead of repeating it unchanged, and verify child evidence before reporting completion. Explicit per-call routing overrides named-agent frontmatter; frontmatter can otherwise override model, provider, or both. A model-only override keeps the inherited provider and uses the frontmatter model. A provider+model override uses both frontmatter values. A provider-only override passes the frontmatter provider and intentionally omits the inherited model, so the child resolves that provider's default model through normal CLI startup. With a provider, the model value is that provider's model id, and the child receives it as the canonical provider/model so ids containing a slash (OpenRouter's openrouter/auto) round-trip unchanged. The child normally returns its final text, so long or self-contained work (research, a scoped edit, a review pass) stays out of the parent's context. If the child fails, times out, is cancelled, exits without an exit event, or answers with stop-reason = :error, the tool returns visible diagnostic text instead of an empty result. A successful child with empty final text returns a non-error diagnostic summary so callers can distinguish "no final text" from a failed child.

The live child

Each run spawns exactly one child, fen --presenter rpc --system-file PATH --no-session plus its routing and tool-policy flags, and drives it over the wire protocol. The task, prefixed with the requested, effective, and physical cwd, is the child's first prompt. The parent keeps one conversation per run and maps every mid-run change to a control message; nothing restarts:

Run status comes only from the child's exit event and the process exit: done with a result whose last assistant stopped normally completes the run (an error, tool-use, or aborted stop fails it), cancelled, failed, and timed-out end it with that status, and a child that exits without an exit event failed. Without a result, the tool reports the latest assistant text the child streamed. cancel and finalize land only at the child's next cooperative yield, so the parent keeps a backstop: a child that has not exited a few seconds after cancel or its own exit is killed, and a finalize with no exit within two minutes is cancelled. The kill reaches the child's process group but not tool subprocesses that started their own sessions, so the parent always tries cancel first. The child also gets the run deadline as FEN_WIRE_DEADLINE, and the process timeout trails it by five seconds.

Children stay --no-session: fen has no per-process session location, and a child session in the user's store would clutter /resume and race --continue. The run's retained event tail and /subagents show are the inspectable record. Blocking and background runs share one driver; a blocking call pumps its child until the run settles while yielding to the parent turn. Because the child is a spawned fen, changes to the spawn(argv, env) path in the fen_process C binding need a full nix build .#fen / make dev-nix rather than a bare /reload.

Run status and cancellation

The subagent extension tracks active and recent child runs. A status-line item appears while child runs are active, for example subagent:1 running. Use /subagents to list active and recent runs with run id, agent, parent-facing status (running, done, timed-out, failed, cancelled, or budget-limited), elapsed time, and turn/tool-call counts. Use /subagents show RUN_ID to render a running job's live retained activity tail or a finished run's retained transcript, final result, and separate process-output tail. The parent appends the child's forwarded display events to the run, retaining the latest 50 events per run across at most 20 runs. Renderable progress keeps canonical presenter event shapes, so subagent workspaces reuse the same streaming, Markdown, thinking, tool pairing, truncation preview, error, scrolling, and selection pipeline as the main transcript. If a workspace falls behind retention, it displays an explicit omitted-events row before replaying the retained canonical tail. Malformed or invalid child lines are counted as event errors and skipped; a wire version mismatch fails the run. When a child times out or fails, its tool result includes a compact tail of the latest child tool/text/error events and records whether partial assistant text was observed. Each run also records time-to-first-artifact when the child first produces a final answer, an edit/write mutation, a failing tool result, or a bash result containing a diff. Pure discovery events remain visible in the progress tail but do not count as artifacts. Launches may set artifact-checkpoint-seconds; active runs that exceed it with no artifact show none! in /subagents and time-to-first-artifact-ms: none yet in /subagents show, and the budget is enforced with finalize as described above. Run details expose the turn/tool counters, the configured budgets, whether budget finalization fired and why, and any repeated-inspection warnings. Repeated read/grep/find/ls/bash inspection fingerprints are tracked from the event stream so timeouts after repeatedly inspecting the same file or query are visible. Before a launch, retained history is also checked for the same normalized agent/task/cwd/provider/model fingerprint: the third and later timeout with no artifact/mutation warns the caller and /subagents to steer a retained run or change the plan instead of starting another identical child. The warning count is bounded by the 20 retained runs and says when eviction has truncated its history. A steer the child rejects (for example after the run is closing or finalizing) is recorded as a steering-rejected event. Use /subagents cancel RUN_ID to cancel one active run, or /subagents cancel to cancel every active run and request cancellation of the current turn. The same lifecycle operations are available agentically through the subagent tool's management actions. models refreshes configured provider catalogs and lists exact authenticated provider/model pairs for a subsequent launch. list and show inspect runs, wait cooperatively awaits completion, steer redirects active work, cancel and cancel-all stop children, retry relaunches a retained background run, and remove deletes one inactive record. clear removes all inactive history, while the explicitly destructive reset cancels detached work and clears history. Management results include structured run data in details, so the main agent does not need to parse their human-readable text. Blocking remains the launch default; background: true returns immediately with a run id and the TUI pumps the child on runtime ticks. /new cancels and synchronously reaps detached children, clears old run history, and removes their workspaces so a fresh conversation cannot inherit stale jobs or tabs. Background records whose job disappears are finalized as failed rather than remaining running forever. Completed run details expose time-to-first-artifact-ms, first-artifact-kind, and first-artifact-summary when an artifact was observed.

Token usage telemetry

Each run accumulates provider-reported token usage as it arrives. Per-turn :llm-end usage is folded into durable run totals as events drain, so completed-turn usage survives event-retention truncation and runs that end without a result. The child's result usage sums its whole conversation and replaces the event-derived total rather than being added to it, so usage is never double counted.

Run details expose usage (input, output, cache-read, cache-write, reasoning, total-tokens), usage-turns, usage-provenance (per-field provider-reported versus estimated; a total derived from input+output is flagged estimated), usage-source (final-result or events), and usage-complete?. Event-only totals are marked incomplete because an in-flight final turn may be unaccounted.

Inspect usage from the TUI or the tool:

(subagent {:action "usage"})               ; per-run table plus workflow totals
(subagent {:action "usage" :run-id "subagent-3"})

/subagents usage [RUN_ID] renders the same view, with a TOTAL row and a by-provider/model/outcome summary. The state introspector and action=usage structured details expose the same fields without scraping rendered text. Context estimates such as approx-context remain transient status information and are not a substitute for cumulative provider usage.

Agent discovery

Agents are markdown-with-frontmatter files, discovered like skills. Roots, in precedence order (project beats user beats bundled):

Use /agents to list the discovered agents, their project/user scope, explicit provider/model overrides, effective timeout, and description. The subagent extension also adds a compact prompt fragment when agents exist and the subagent tool is available to the model. It advertises only stable launch names and descriptions so the model can choose names for the subagent tool without receiving local paths or override details. The fragment is capped; run /agents for the full discovered list.

An agent is launched by the .md filename (without extension). The frontmatter name: is descriptive metadata and should normally match the filename. Format:

---
name: scout
description: Fast read-only recon
model: claude-haiku-4-5
provider: anthropic
timeout-seconds: 2700
max-turns: 4
max-tool-calls: 10
tools: read, grep, find, ls
---
You are a scout. Briefly answer the question and stop.

name and description are required; model, provider, and timeout-seconds are optional. Frontmatter values run to the end of the line, so don't add inline # comments — they become part of the value. Omitting both model and provider makes the child inherit the parent provider/model when available. Setting only model inherits the parent provider and uses that frontmatter model. Setting both provider and model pins both. Setting only provider passes that provider and intentionally omits the parent model, so the child uses normal CLI default-model resolution for that provider. timeout-seconds defaults to 2700 (45 minutes). max-turns and max-tool-calls are optional launch budgets for bounded workflows such as reviews; reaching one makes the child finalize rather than silently looping through more inspection. tools is an optional comma- or whitespace-separated hard allowlist passed to the child as --tools; omit it to leave the child's full tool set available. Parent CLI restrictions always apply to named and inline children: parent --tools intersects this allowlist, parent --denied-tools removes denied names or is forwarded when no child allowlist exists, and parent --no-tools forces --no-tools. A child can be narrower than its parent but can never gain a tool the parent lacks, and an empty intersection rejects the launch before a child process is spawned.

The body becomes the child's system prompt (delivered with the --system-file CLI flag). models.json custom providers work automatically because the child reads the same config directory. Ready-to-use default agents scout, reviewer, and planner are bundled with fen. Copy-pasteable source copies live in extensions/behaviors/companions/subagent/examples/. Drop one into .fen/agents/ or the user agents directory only when you want to customize or override the bundled definition.

Tool

The subagent tool either launches a child or manages existing runs. A launch says who the child is (a named agent or inline prompt) and what it should do (task), plus optional routing and cwd controls. A management call supplies action and, for per-run actions, run-id. Subagent tool argument names use the hyphenated spellings in this table.

Parameters:

Tool table
ParameterRequiredPurpose
actionmanagement onlymodels refreshes and lists authenticated model routing pairs; review-worktrees creates detached sibling review worktrees; cleanup-review-worktrees safely removes unchanged workflow-created review worktrees; usage reports per-run and workflow token usage; list, show, wait, steer, cancel, cancel-all, remove, retry, clear, and reset manage runs.
run-idper-run actionsStable run id required by show, wait, steer, cancel, remove, and retry.
notesteerSteering note delivered to the live child at its next turn boundary.
tasklaunch onlyThe work handed to the child, delivered as its first user message. What to do.
agentone of agent/promptName of a discovered agent definition (the .md filename without extension). Who the child is.
promptone of agent/promptInline system prompt used directly as the child's persona, so no agent file is needed. Who the child is.
cwdoptionalWorking directory for the child or review-worktrees source; validated to exist. Defaults to the parent's cwd.
refreview-worktrees onlyGit revision to check out detached, defaulting to HEAD.
worktree-countreview-worktrees onlyNumber of detached sibling review worktrees to create, from one through four.
modeloptionalOverride the child model. Defaults to agent frontmatter, else the inherited parent model.
provideroptionalOverride the child provider. A provider-only override omits the inherited model.
timeout-secondsoptionalFor launches, shorten the child budget within policy; for wait, set the polling budget (default 30 seconds).
max-turnsoptionalLaunch-time budget for completed child LLM turns; reaching it before a final answer sends finalize, so the child returns findings now without tools.
max-tool-callsoptionalLaunch-time budget for child tool calls; reaching it before a final answer sends finalize, so the child returns findings now without tools.
artifact-checkpoint-secondsoptionalLaunch-time no-progress budget; when no artifact appears within it the parent sends finalize, like max-turns/max-tool-calls, and records the no-artifact-yet state.
backgroundoptionalReturn immediately with a run id and pump the detached child from TUI runtime ticks.
collectoptionalQueue a compact summary (default) or full result when a background run completes.

task names the job; agent/prompt name the persona — keep them distinct. When both agent and prompt are supplied, the named agent wins.

Named agent:

(subagent {:agent "scout"
           :task "what files define the provider interface?"
           :cwd "."})        ; cwd optional; validated to exist

Inline prompt — runs without any agent file, using the prompt as the child's system prompt directly:

(subagent {:prompt "You are a one-off reviewer. Answer briefly and stop."
           :task "summarize the risk in the current diff"
           :model "claude-haiku-4-5"   ; optional inline routing
           :provider "anthropic"        ; optional inline routing
           :timeout-seconds 120})       ; optional per-call budget

Per-call timeout-seconds works for named and inline agents. It may shorten a run but cannot raise the agent's configured timeout or the 2700-second (45-minute) default ceiling. Set artifact-checkpoint-seconds when the parent needs an early no-progress signal before the full timeout. For review delegations, prefer short explicit budgets such as timeout-seconds: 300, max-turns: 4, and max-tool-calls: 10; longer timeouts should be an explicit operator choice, not the default response to a no-artifact review loop. Inline model and provider follow the same routing policy as equivalent agent frontmatter, so a provider-only inline override also omits the inherited model. Prefer a named agent when you want reviewable, reusable policy; use an inline prompt for a quick one-off delegation that isn't worth a file.

For parallel review, create detached siblings first, then launch ordinary reviewer or scout calls concurrently with the returned details.worktrees[*].path as each child cwd. The parent remains the only process that applies edits. Each child receives cwd context automatically, but its task should still begin with pwd, git status --short, and the expected diff/ref preflight.

(subagent {:action "review-worktrees" :ref "main" :worktree-count 2})
;; Launch up to two ordinary read-only reviewer/scout calls in parallel using
;; the returned paths, then inspect their evidence and apply any fixes here.
(subagent {:action "cleanup-review-worktrees"}) ; removes only unchanged paths created above

The helper never checks out or removes the caller's worktree. Cleanup refuses any path not recorded as workflow-created and refuses a review worktree with changed status or HEAD.

Agentic management examples:

(subagent {:action "list"})
(subagent {:action "show" :run-id "subagent-2"})
(subagent {:action "wait" :run-id "subagent-2" :timeout-seconds 30})
(subagent {:action "steer" :run-id "subagent-2" :note "focus on tests"})
(subagent {:action "cancel" :run-id "subagent-2"})
(subagent {:action "retry" :run-id "subagent-2"})
(subagent {:action "remove" :run-id "subagent-2"})
(subagent {:action "cancel-all"})
(subagent {:action "clear"})
(subagent {:action "reset"})

clear only removes inactive history and rejects while runs are active. reset is the explicit destructive convenience operation: it cancels and reaps detached children before clearing history. A blocking child belongs to its parent turn, so reset requests turn cancellation and reports that it must be retried after the blocking child exits.

Simplify companion

The first-party simplify extension (extensions/behaviors/companions/simplify/) adds /simplify for a quality-only cleanup pass over changed code: reuse, simplification, efficiency, and altitude. It does not hunt for or fix bugs and does not change behavior; use a dedicated bug-focused review for correctness.

It is composition over existing primitives. The command computes the changed-file set (shelling git via process.run-captured) and submits a structured turn with api.turn.submit!. The submitted prompt drives the main agent to fan out one read-only subagent reviewer per changed file (using the bundled simplifier agent), consolidate the findings, discard anything risky or behavior-changing, then apply the safe simplifications and print a summary. Subagents only review; the parent applies the edits so they stay coherent under one coordinator.

Subcommands

Subcommands table
commandeffect
/simplifyReview and apply quality cleanups on the current working-tree changes.
/simplify <ref>Simplify changes since <ref> (for example /simplify main).
/simplify showReprint the last simplify summary.
/simplify helpPrint usage.

While a simplify turn runs, a left-side simplify:running status item is shown, and an :introspect snapshot exposes the status and last summary for /extensions simplify. When git is unavailable or the directory is not a repo, the command still runs and asks the model to discover the diff itself; when there are no changes it exits early without starting a turn.

The simplifier agent

/simplify delegates per-file review to a simplifier agent. A ready-to-use definition ships at extensions/behaviors/companions/simplify/examples/simplifier.md; copy it into .fen/agents/ (project) or ${XDG_CONFIG_HOME:-~/.config}/fen/agents/ (user) to enable isolated review. If no simplifier agent is found the command prints a one-line hint and proceeds with inline review instead, so it still works without the drop-in.

Skills companion

The first-party skills extension (extensions/behaviors/companions/skills/) discovers and exposes Agent Skills.

Discovery

SKILL.md files are discovered recursively from these roots, in priority order:

  1. ${XDG_CONFIG_HOME:-~/.config}/fen/skills (user)
  2. ./.fen/skills (project)
  3. ~/.pi/agent/skills, ~/.agents/skills, ~/.claude/skills, ~/.codex/skills (user)
  4. .pi/skills, .agents/skills, .claude/skills, .codex/skills in the working directory first and then each ancestor through and including the nearest VCS root, with the nearest copy winning; without a VCS marker, traversal continues through the filesystem root (project)
  5. paths passed with --skill <path>
  6. bundled fen skills, materialized under ${XDG_DATA_HOME:-~/.local/share}/fen/skills/bundled

Skills are deduplicated by canonical path and then by name; the first match wins, and each winner records later copies in its shadowed-paths list in discovery order. Use fen list skills --json or fen show skill <name> --json to inspect a winning skill's shadowed copies. Because bundled skills scan last, any user, project, or --skill copy with the same name shadows the bundled one. Set FEN_DISABLE_BUNDLED_SKILLS=1 to skip bundled-skill materialization and discovery.

Discovery stops descending at a directory that contains SKILL.md. It skips dotdirs, node_modules, and paths matched by .gitignore, .ignore, or .fdignore. .pi/skills also accepts direct *.md skill files; the other roots require <name>/SKILL.md directories.

Frontmatter

Frontmatter is minimal YAML. description is required; name is optional and falls back to the directory (or file) name. disable-model-invocation: true keeps a discovered skill out of the prompt catalogue and the skill tool. Other keys, such as Claude Code's user-invocable, are ignored.

The description is the only text the model sees before choosing a skill, so say what the skill does and when to use it.

Prompt and loading

The system prompt carries a compact catalogue of visible skills: one - name: description line each, without paths. The model loads a skill's full instructions with the skill tool ({name: "<skill>"}), which it activates through tool_search. The tool result includes the skill's directory so relative references inside the skill resolve with read.

Subcommands

Subcommands table
commandeffect
/skillsOpen the skills picker/detail panel.
/skills <name>Jump directly to one skill.
/skills listText list with scopes, visibility, and paths.
`/skills visible\hidden\builtin\user\project\cli`Filtered list views.

The extension also exposes a discovered-skills introspection snapshot for /extensions skills and diagnostics.

Steering queue service

The first-party steering extension (extensions/behaviors/kernel/steering/) owns the interactive input queues: steering lines injected into the running turn at safe boundaries, and >-prefixed follow-up lines started as fresh turns after the current turn completes. Extracting it moved the queue tables, drain modes, and >-prefix policy out of main.fnl's run state (issue #53 phase 1); the run loop only wires the agent's get-steering/get-follow-up callbacks and acts on the :start decision.

The service API lives in fen.extensions.steering.service:

Steering queue service table
functioneffect
(submit line ctx)Decide non-slash input: {:action :start} when idle, else queue (steering, or follow-up after stripping >).
(handle-input input ctx)Input-pipeline wrapper around submit; registered as the default :input-handler at order 1000 (see below).
(get-steering) / (get-follow-up)Drain a queue by its mode; wired as the agent callbacks.
(queue! kind text)Append a line and emit :queued plus refreshed status counts.
(requeue! text from to)Move the latest still-pending copy of a line to the other queue, emitting the same events as queue!.
(clear-queues! ?kind)Empty one queue or both (/cancel-all, /new, /resume, /handoff).
(set-queue-mode! kind mode):one-at-a-time (default) or :all.
(queue-info) / (queue-snapshot)Counts+modes for status, copied contents for UI such as the /queue panel.

Queue state lives in the non-reloadable fen.extensions.steering.state, so pending input survives /reload. Cross-extension consumers (the /queue inspector, sessions/handoff resets, the agent_state tool) require the service module rather than the fen.extensions.steering entry: the loader cache-busts entry modules on a fresh load!, while non-entry modules keep one table identity that /reload mutates in place.

Input-handler pipeline

Non-slash user input is dispatched through an ordered :input-handler pipeline before a turn starts (issue #53 phase 2), rather than through the notification event bus — api.emit ignores return values, so it is a poor fit for ordered input transforms/intercepts. The run loop calls fen.core.extensions.input.handle with {:kind :user-input :text line} and a small runtime-owned context {:busy? bool :state runtime-state}.

Handlers register with (api.register :input-handler {:name ... :order ... :handle fn}) and run in ascending :order. Each returns a structured action:

The first non-:continue action wins; a handler that throws is skipped rather than wedging input. If every handler passes, the runtime starts a turn with the final text. The steering extension registers the default/fallback handler at order 1000, so other extensions (macro expansion, planners, subagent routing) can run before it.

Experimental: decide

The first-party decide extension (extensions/behaviors/kernel/decide/) provides fast advisory judgment calls using either TypeSafe's Jev model on OpenRouter or the OpenAI Decisions API (gpt-6-luna). These decisions replace fixed heuristics or avoid full-context calls to the main model for tasks like tool-result pruning, auto-compaction timing, topic-shift hints, and busy-line routing. It is an experiment and is not recommended for general use yet. With the extension disabled, fen behaves as if it did not exist. Decisions never touch the main transcript or its prompt-cache prefix.

It is off by default. Enable it with /extensions enable decide or "extensions": {"decide": {"enabled": true}} in settings.json (see Discovery).

Backends and Credentials

Decide supports two pluggable backends:

There is no silent backend fallback or cross-provider data transmission. If the configured backend fails, returns an error, or lacks credentials, the service returns nil and consumers retain their fallback behavior.

Settings under extensions.decide:

Backends and Credentials table
keydefaultmeaning
backend"jev"Decisions backend: "jev" (OpenRouter) or "openai" (OpenAI Decisions API).
modelbackend defaultDecisions API model id (~typesafe/jev-latest for Jev, gpt-6-luna for OpenAI).
timeoutMs3000Overall request timeout; the connect timeout is capped at 2000 ms.

Consumers

Every consumer is advisory: without an answer, fen keeps its behavior without decide.

The compaction consumers live in fen.extensions.decide.compaction, the only decide module compact calls. The input consumers share one :input-handler at order 900, before the steering fallback, which never changes or holds the input; a failure is logged and the line proceeds.

Service API

The service API lives in fen.extensions.decide.service; require it rather than the entry module, as with the steering service:

Service API table
functioneffect
(enabled?)Whether the extension is loaded now; false after /extensions disable decide.
(ask state questions ?opts)Ask about one JSON-encodable state; returns answers keyed by question id, or nil.
(ask-async! state questions on-done)Run ask as a background task and call (on-done answers-or-nil).
max-request-bytesCap on one encoded request (state plus questions); larger requests return nil without a call.

questions maps a string id to {:type :noul|:choice :instructions text :criteria {...}}, several per request. A :noul question's criteria describe :true and :false; its answer is {:type :noul :noul p} with p the probability of true. A :choice question's criteria map each option to a description; its answer is {:type :choice :choice option :probabilities {option p} :confidence c}. Question wording and thresholds belong to each consumer.

Every failure returns nil and logs to the extension log: disabled extension, missing key, invalid questions, a request over the size guard, timeout, HTTP error, or a malformed or incomplete response. Callers keep their non-decide behavior on nil, so no decision may be required for correctness. Consumers own truncating their state to fit. The one exception is an error raised by the caller's yield function, such as a turn's cancellation, which propagates unchanged.

Pick the call style by what the caller already has:

Reload behavior

/reload does two things:

  1. reloads eligible loaded core modules through fen.core.extensions.loader.reload
  2. asks fen.core.extensions.loader to reload each loaded extension

The model-facing reload tool only queues a request and requires both a scope (reload or registries) and a human-readable reason. The runtime records the request with api.log and executes one request at the idle boundary after the active agent turn, stream, and tool calls have finished. It dispatches the existing /reload command rather than reloading synchronously from the tool.

The default reload scope is ordinary /reload, so it preserves conversation history, active session metadata, persistent UI/runtime identity, logs, diagnostics, and trusted source overlays. The explicit registries recovery scope clears only extension event handlers, registration buckets, extension loader records, reload fingerprints, the active presenter slot, and the cached session-backend implementation. It preserves fen.core.extensions.state identity and all other buckets, including the session selection/info/handle, UI wrapper table, logs, errors, runtime info, and development overlay. Recovery then forces the normal core and extension bootstrap paths to rebuild registrations and the active presenter from known entries. The request/execution messages and reload summary identify the requested scope, reset buckets, preserved buckets, reload work, and failures. Restarting fen and using /resume remains the full-process escape hatch when scoped recovery cannot restore a usable runtime.

Every extension contribution is owner-tagged. Core stores this metadata in the reserved internal field :__owner; public introspection lists expose it as :owner. Do not put your own data in fields beginning with __.

Reload starts by removing all registrations for that owner, then re-runs the entrypoint. The built-in /reload command runs this work from a coroutine and passes a cooperative yield! callback into the loader so the TUI can keep painting between core module batches and fully reloaded extension specs. The active TUI presenter is skipped during the cooperative extension pass and then reloaded once at the end, followed by a single :reinit-presenter full redraw.

Extension code should follow the same rule: any command, hook, provider, tool, or register-time operation that may block should accept a ?yield-fn or use the callback supplied by the runtime, and call it between chunks of work. Avoid long CPU loops, blocking subprocess drains, large filesystem scans, or network waits inside presenter callbacks without yielding.

For module-shaped extensions, the manifest controls what is cleared from package.loaded:

:reload-modules [:fen.extensions.tui.markdown
                 :fen.extensions.tui.paint
                 :fen.extensions.tui.input
                 :fen.extensions.tui]
:reload-exclude [:fen.extensions.tui.state]

This lets behavior reload while persistent state survives. The TUI uses this to reload rendering/input/registration code while preserving termbox lifecycle, transcript, scroll position, and status state.

session-backend unregister has one extra side effect: if the removed backend is currently active, core clears the active backend and cached session info too. Keeping a stale active backend reference would route later appends into removed code.

Use /reload-extension <name> to reload one already-loaded external extension. The command rebuilds the agent afterward so changed tools and prompt fragments take effect while preserving conversation messages.

Introspection

Interactive commands:

/docs [topic] [name]
/extensions
/extensions <name>
/extensions registry [kind]
/extensions enable <name>
/extensions disable <name>
/reload-extension <name>

/docs browses runtime documentation from live registries and structured contracts, including commands, tools, providers, events, canonical types, and register kinds. /extensions <name> shows manifest details plus the live commands, tools, panels, status items, prompt fragments, event handlers, hooks, and other registry contributions currently owned by that extension. /extensions registry [kind] shows the live registry grouped by kind, with stable owner labels for debugging reload cleanup and duplicate registrations. /extensions enable|disable <name> persist the settings flag described in Discovery.

Programmatic API:

(api.list :extensions)
(api.list :commands)
(api.list :tools)
(api.list :presenters)
(api.list :event-handlers)
(api.list :prompt-fragments)
(api.list :status)
(api.list :panels)
(api.list :hooks)

Lists are frozen deep copies intended for inspection, not mutation. Hot list kinds are memoized on a registry mutation counter, so repeated calls may return the same frozen snapshot until a register/unregister occurs.

Packaging and dependencies

Flat manifest directories are the authoring shape for first-party and project-local extensions. Rockspecs remain useful for publishing and declaring extension dependencies, but users should build local extension dependencies via fen rather than invoking LuaRocks directly:

fen ext build .fen/extensions/myext

fen ext build DIR expects exactly one *.rockspec in DIR and installs into ${XDG_DATA_HOME:-~/.local/share}/fen/rocks by default. Set FEN_ROCKS_TREE to override the tree. Fen prepends the tree's Lua 5.4 share/lua and lib/lua paths to the runtime search path on startup when the tree exists.

fen ext build compiles the extension's Fennel sources in process with the embedded compiler (fen.core.extensions.build), so it needs neither a system fennel nor a fen source checkout, then installs the result through the bundled LuaRocks runtime. It drops a .lrbuild/.fen-precompiled marker so the rockspec's own build_command skips its bootstrap compile. That build_command is a one-line call into scripts/build/fennel-build.fnl for standalone luarocks make builds without fen, which set FEN_WORKSPACE to the fen source root; the single-file binary remains the canonical way to build extensions.

If extension loading fails with Lua's standard module 'X' not found error, the loader surfaces an actionable message: fen ext build <dir> when the extension directory has a rockspec, or a manual luarocks install --tree ... X command when it does not. Manifests may optionally declare :requires-modules [...] to probe lazy dependencies before loading and report all missing modules at once. :requires-shared-libs [...] is diagnostic text only; fen does not install system libraries.

The single-file binary bundles the local-only LuaRocks runtime, lfs, dkjson, and the LuaSocket modules needed by first-party presenters, so pure-Lua local rockspec builds do not need system luarocks. The bundled path intentionally does not include LuaSec or the luarocks.org network/download workflow. Native rocks still require a system C toolchain and Lua development headers; set LUA, LUA_INCDIR, and related LuaRocks variables when needed.

Minimal extension example

A path-shaped extension under your user config extension dir. No :entry-module, no rock — just a directory with an init.fnl and, for global discovery, a manifest that enables it.

~/.config/fen/extensions/hello/
  manifest.fnl
  init.fnl

manifest.fnl:

{:name :hello
 :description "Hello command"
 :enabled-by-default true}

init.fnl:

(fn [api]
  (api.register :command
                {:name :hello
                 :description "Show a greeting"
                 :handler (fn [args _ctx]
                            (api.emit {:type :assistant-text
                                       :text (if (= args "")
                                                 "hello"
                                                 (.. "hello " args))}))}))

For a project-local version, place the same directory under .fen/extensions/hello/; the manifest may be omitted entirely because the extension name falls back to the directory name and project-local drop-ins are enabled automatically.

For a tiny one-file project extension, use .fen/extensions/hello.fnl:

(fn [api]
  (api.register :command
                {:name :hello
                 :description "Show a greeting"
                 :handler (fn [args _ctx]
                            (api.emit {:type :assistant-text
                                       :text (if (= args "")
                                                 "hello"
                                                 (.. "hello " args))}))}))

Run fen and type /hello world. In the source checkout, prefer the single-file dev wrapper:

nix build .#fen
FEN_BIN=$PWD/result/bin/fen scripts/dev/fen-dev

Installed/package users can run bin/fen directly.

For ad-hoc testing of an extension dir that isn't on a discovery root:

FEN_BIN=$PWD/result/bin/fen scripts/dev/fen-dev --extension /path/to/hello

--extension accepts a manifest dir or a single .fnl/.lua file, and always loads regardless of :enabled-by-default.

Claude Code memory interoperability

The optional claude_memory extension exposes Claude Code's project memory index read-only. Enable it with /extensions enable claude_memory and inspect resolution with /claude-memory. It adds at most 8192 bytes of MEMORY.md, plus provenance and stale-context guidance, to the prompt; it never automatically imports linked memory bodies or writes memory files. Use existing file tools to read relevant linked files relative to the reported memory directory. Current project instructions take precedence; historical memories and Claude-specific orchestration guidance are not authoritative fen instructions.

By default, resolution uses the physical nearest Git worktree root (or the launch directory outside a repository), replaces every non-alphanumeric character with -, and looks in ~/.claude/projects/<project-key>/memory/MEMORY.md. Each worktree uses its own path key, not the main checkout's key; subdirectories within a Git worktree use its root. Claude path naming can vary, so no search of unrelated project directories or implicit main-worktree fallback is attempted. Set an absolute directory override when the mapping differs or when deliberately sharing main-checkout memories:

{"extensions": {"claude_memory": {"enabled": true, "directory": "/home/me/.claude/projects/-my-project/memory"}}}

Missing default indexes add nothing to the prompt and produce no startup notice. The inspection command reports missing/unreadable indexes, invalid overrides, inclusion, and truncation. Settings and the index are read when the prompt is assembled or inspected; /reload refreshes the extension normally. Enabling this extension sends the included index to the active model provider as prompt context, so review its contents first.