Parity ledger

August 29, 2026 · View on GitHub

xfx is an unofficial behavioral port of vercel-labs/fx, pinned to 580a0c5da9386317251968c09c1cee69e763487a. This file is the product's truth about what it can actually do.

Read it this way: a surface that is not implemented here is absent from the binary. It is not a hidden flag, a silent no-op, or a stub that returns success. Deferred rows exist so this document can be honest about the gap between xfx and upstream, not so the gap can be advertised as a feature.

Status values

ValueMeaning
implementedComplete for the documented contract, with a green acceptance test.
partialPresent and useful, but narrower than upstream. The row states the limit.
deferredAbsent from the binary. Not in help, not in a tool schema, not a stub.

scripts/check-no-stubs.sh reconciles this file against the binary in both directions, for commands, tools, and shell slash commands:

  • every surface the binary advertises has an implemented row here;
  • every implemented row names a surface the binary really advertises, so "implemented" cannot be claimed for something that does not exist;
  • no name from a deferred row -- including the names listed inside a grouped row, such as delete_file or /resume -- is advertised anywhere; and
  • no surface name appears in two rows.

tests/parity.rs proves the same reconciliation against the running binary rather than against the source text: the parser's own subcommand list, the tool schemas as they are serialized into a Gateway request, and the rendered help pages.

Commands

Upstream's command union is src/core/cli/cli_surface.zig:58-84.

SurfaceKindStatusNotes and upstream evidence
statuscommandimplemented[--json]. Model, provider, credential source, permission mode, sandbox, workspace, history turns, step limit. cli_surface.zig:69, output_contracts.zig:489-540.
doctorcommandimplemented[--json]. Aggregate counts plus {name,status,detail} checks: workspace, config, provider, auth, permissions, sessions, startup. The provider check appears only when the configured provider cannot run -- an unreadable backend value, or backend: llmux with no usable llmux_url -- and fails, because every turn on such a machine refuses; it names xfx setup llmux or quotes the unreadable value. A provider that works is reported by the snapshot's provider/provider_url fields instead. The auth check asks each provider its own question: the Gateway fails without a bearer credential, llmux passes without one, because a loopback request is accepted keyless. No network I/O: doctor stays a command that is always safe to run, and never probes the daemon. The sessions check reports how many sessions are recorded, how many directories could not be trusted, and how many staged manifests an interrupted write left behind -- a report, never a repair. cli_surface.zig:73, output_contracts.zig:1209-1285.
helpcommandimplementedhelp, --help, -h. Lists only implemented commands. cli_surface.zig:60.
askcommandimplemented[--auto|--yolo] [--json] [--no-save] [--add-dir <PATH>]... [--resume <last|ID>|--resume-id <ID>] <prompt>. A bounded multi-step Gateway turn: ordered assistant text, tool calls executed locally under a permission authority, then exactly one terminal event. Ctrl-C cancels the turn and kills any running command's process group; a second Ctrl-C exits 130. --no-save, --add-dir, the resume flags, and the permission modes have their own rows. cli_surface.zig:61.
interactivecommandimplementedWhat a bare xfx runs. A line-oriented append shell on the terminal's own canonical mode: it never enters raw mode or the alternate screen, so scrollback is preserved and there is no terminal state to restore. It refuses to start without a terminal on both stdin and stdout, and without a place to record the conversation. Each prompt is one ordinary turn -- same provider, registry, permission authority, and session store as ask -- and it owns exactly the seven slash rows below. Ctrl-C stops a running turn and a second one exits 130; at the prompt it clears the line, and twice in a row leaves. It has no name to type: the parser reaches it by a bare invocation, which src/cli.rs declares as ADVERTISED_ENTRYPOINTS. cli_surface.zig:59, app_entry_runtime.zig:224. XFX_TUI=1 runs the partial TUI in its place; the shell described here is what every other invocation gets.
sessioncommandimplemented<last|ID>|--id <ID> [--json]. Replays one session's log through its published boundary and cross-checks it against the manifest, then renders bounded turns. Read-only: it creates no profile state, and a session it cannot trust is a named refusal rather than a partial read. cli_surface.zig:76.
sessionscommandimplemented[--json] [--all] [--limit N]. Newest first with a total order, scoped to the current workspace unless --all, bounded at 20 by default and 200 at most. A session directory that cannot be trusted is counted in skipped_invalid rather than failing the listing. Read-only. cli_surface.zig:77.
resumecommanddeferredUpstream's standalone resume_session command. xfx resumes through ask --resume/--resume-id, so the bare name is not advertised. cli_surface.zig:78.
acpcommanddeferredAgent Client Protocol server. Post-v0.1. cli_surface.zig:62.
prcommanddeferredGitHub pull-request workflow. Post-v0.1. cli_surface.zig:63.
issuecommanddeferredGitHub issue workflow. Post-v0.1. cli_surface.zig:64.
logincommanddeferredVercel OAuth. xfx reads environment credentials only. cli_surface.zig:65.
logoutcommanddeferredRequires stored credentials, which xfx does not keep. cli_surface.zig:66.
setupcommandimplemented<gateway|llmux> [--url URL] [--json]. Selects the provider a turn will talk to and records it. This is xfx's provider-switching surface, in the same place upstream put it when it removed /provider in 0.0.5. llmux additionally discovers and proves a daemon: discovers it (explicit --url, else the url a previous setup recorded, else http://127.0.0.1:3456, else the proxy.port in llmux's own config -- never a scan, never off this machine; holds --url to the same loopback-service policy and refuses a remote or pathed one before any network I/O), proves it is llmux (GET / answers exactly llmux and GET /models answers a non-empty catalog, each read through a bounded stream), keeps the profile file's own model when the catalog has it and otherwise takes the catalog's first entry -- the decision is about the layer being written, not the fully resolved value -- and merges backend/llmux_url/model into ~/.xfx/settings.json through a staged 0600 file and a rename -- preserving every unrelated key, and refusing rather than replacing settings it could not parse. It writes provider and models[<target>] alongside backend and model, so a v0.1.0 binary reading the same profile still resolves the endpoint the operator chose. When a higher layer (XFX_MODEL, or an exact-workspace entry) will still outrank what was written, it says so on stderr and in overridden_by. It sends no completion request: the ping and the catalog are the whole receipt. It reads exactly one field of llmux's config, proxy.port, and never any credential. gateway performs no network I/O: it has no daemon to probe and advertises no catalog, so it records the selection, keeps the profile's model for that provider, and names the credential the environment supplies. It is not credential onboarding: no key is read from a prompt and none is written, and a target with no resolvable credential is recorded with a warning naming the two variables rather than refused, because the profile is machine state and the environment is shell state. Upstream's interactive Gateway onboarding remains absent. cli_surface.zig:67.
permissionscommanddeferredPermission rule management UI. cli_surface.zig:70.
modelscommanddeferredModel catalog. cli_surface.zig:71.
providercommanddeferredProvider switching, including Codex. xfx implements provider switching as xfx setup <provider>, which is where upstream moved it in 0.0.5; the standalone provider command name is not advertised. cli_surface.zig:72.
backgroundcommanddeferredBackground and durable work. cli_surface.zig:74.
teamscommanddeferredVercel team selection; needs login. cli_surface.zig:75.
creditscommanddeferredBilling surface; needs login. cli_surface.zig:79.
usagecommanddeferredUsage reporting; needs login. cli_surface.zig:80.
upgradecommanddeferredSelf-updater and release channels. cli_surface.zig:81.
replaycommanddeferredGolden terminal replay; needs the full-screen UI. cli_surface.zig:82.
workspacecommanddeferredAdditional-root management. cli_surface.zig:83.

Interactive shell commands

Upstream's slash palette is the ~40 entries in src/builtins/commands.zig:414-457. xfx's shell owns the seven below and nothing else; scripts/check-no-stubs.sh reconciles them against SLASH_COMMANDS in src/interactive.rs, in both directions.

An alias is not a command of its own, and it does not get a row of its own. SLASH_REGISTRY, beside that list, carries each command's aliases as metadata on it, and the same script reconciles those aliases against the notes of the row of the command they name -- both ways, so a name the shell answers with no row is a failure and a row claiming an alias the shell does not answer is a failure too. There is exactly one today, /exit on /quit, and it is why the canonical count is seven while the number of names the parser accepts is eight.

SurfaceKindStatusNotes and upstream evidence
/helpslashimplementedLists every canonical command with one line each, then says that anything else is a prompt. The page is generated from SLASH_REGISTRY in its own order, so it cannot advertise a name the parser does not answer. commands.zig:415.
/newslashimplementedEnds the current session and starts a fresh identity on the next prompt. The old session's writer lock, its "always" grants, and its read proofs are all released with it, because each was sold as being about that session. commands.zig:417.
/clearslashimplementedErases the screen and the scrollback (ESC[2J, ESC[3J) and reprints the header. The conversation is untouched: same session id, same durable history, and the next prompt still carries every earlier turn. It is a display command, not a memory command. commands.zig:416, app_input_runtime.zig:2718.
/modelslashimplementedWith no argument it reports the model, the active provider, and the settings layer that chose it, and browses that provider's catalog when it advertises one -- each entry's published name, context window and effort levels, bounded at 100 rows with an explicit ... and N more line. The catalog load is the one network call /model makes, and it is where a provider that is not answering surfaces; status and doctor still perform no I/O and are still always safe to run. With an argument it uses that model from the next turn on and records a durable preferences_changed event. An id the loaded catalog does not publish is refused by name rather than sent; when the catalog could not be read the selection is accepted and the shell says it was not checked, because a provider that is down must not stop an operator from changing a preference. Bounded to one printable word: the id becomes an HTTP header. The TUI browses too: a bare /model reports the model and the provider at once and hands the load to the runtime thread, which owns the socket -- so a daemon that is not answering never stalls the thread holding the terminal, and the rows arrive as their own event afterwards. That event is bounded to the same 100 rows, and every string in it -- id, display name, effort label -- is made inert at the channel, because a catalog is a document a daemon on a port serves. The TUI's browser reports how many rows it was shown rather than a total it was never told, because the bound is applied before the list crosses the channel; the line shell, which holds the whole catalog, is the surface that reports one. The catalog also supplies the hint row's context denominator. What a model id may be is one question with one answer, and both surfaces ask it of the same function: provider::model::model_id_problem, which is the first thing ModelSelector::apply asks and which the TUI asks directly, in the same order and refusing in the same words -- so an id carrying a control character is refused identically wherever it is typed, and the TUI refuses it before the runtime is told anything, because the far side would write that id into the session log. The catalog-membership refusal is shared as well, and it is asked where the catalog is: ModelSelector::apply holds the whole catalog on the thread that loaded it, and both front ends route their selection through it -- the line shell inline, the TUI as a piece of work on the runtime thread whose answer comes back as the one event that moves the band's own model. So an id the loaded catalog does not publish is refused by name on either surface, and the band goes on showing the model the session is really in: nothing is written into the session log, the hint row does not move, and the next turn is sent under the standing model. The TUI therefore reports a model change only after the runtime has made one -- a band that adopted the id it submitted would show a model the provider does not publish and report it to the next bare /model. When the provider advertises no catalog, when a load failed, or when none has been loaded in this process yet, the selection is accepted and both surfaces say in one shared sentence (provider::model::unverified_notice) that it was not checked, because a provider that is down must not stop an operator changing a preference. Both surfaces record the same durable preferences_changed event, and only for a selection that was really applied. The standalone models and provider commands remain separate deferred rows. commands.zig:452.
/setupslashimplemented`/setup <gateway
/versionslashimplementedThe version, build channel, and build revision of the running binary. commands.zig:456.
/quitslashimplementedLeaves with status 0. Answers to /exit as well -- upstream's own alias for the same command -- which is metadata on this row rather than a canonical name of its own: /help prints one line per command and says the alias on this one, SLASH_COMMANDS does not grow for it, and both front ends accept both spellings because both parse SLASH_REGISTRY. commands.zig:457.
other upstream slash commands (/reset, /resume, /continue, /rename, /login, /logout, /stats, /usage, /status, /background, /image, /images, /models, /provider, /permissions, /allowlist, /undo, /mcp)slashdeferredAbsent from /help and from the parser: an unrecognized slash line is one deterministic refusal that names the command and points at /help. /exit used to be on this list; it is answered now, as an alias on the /quit row above rather than as a command of its own, which is why the count of canonical names did not move for it. /setup was on it too and is a canonical command now, which is why that count went from six to seven. commands.zig:414-457.

Tools

Upstream's registry is the 26 entries in src/builtins/tools.zig:1351-1378. xfx advertises the 8 tools below, in that order, and nothing else: 4 read-only (list_files, glob_files, grep_files, read_file), 3 mutating (write_file, edit_file, create_folder), and 1 command (terminal).

That split is the safety boundary, not a taxonomy. A read-only call is admitted in every permission mode without asking anyone, because it changes nothing. Each of the other four crosses a permission decision that mints a one-use authority for one exact target and revalidates it immediately before spending it: ask stops on every one of them, and auto admits only bounded reversible writes and a reporting-only command grammar. xfx can change files in your workspace and start processes on your machine, under no OS sandbox; those four rows are where that begins.

The registry is a compile-time constant. scripts/check-no-stubs.sh reconciles it against the tool rows below, and tests/parity.rs reconciles the counts and the three groups named in this paragraph against the registry's real PermissionKinds -- so the prose cannot drift from the code any more quietly than a row can.

SurfaceKindStatusNotes and upstream evidence
list_filestoolimplementedOne directory level, sorted, ignored names omitted, capped at 100 entries with an explicit ... and more entries line. tools.zig:509-532, list_files.zig:80-115.
glob_filestoolimplementedpattern plus optional path and mode=matches|count. Sorted before it is capped at 100; skips ignored and gitignored directories; does not follow symlinks. tools.zig:534-562, glob_files.zig:88-245.
grep_filestoolimplementedLiteral substring search with path, include, case_insensitive, mode=matches|files_with_matches|count, head_limit, offset, and context_lines (bounded at 5). Regular expressions are not supported, matching upstream. tools.zig:564-597, grep_files.zig:161-530.
read_filetoolimplementedLine-numbered UTF-8 output with start_line/line_count, 400-line default, 2000-byte line clip, 256 KiB output cap, and an explicit sentinel stating how many of the file's lines were shown. Binary files are named, not dumped, and a target that is not a regular file (a directory, a FIFO, a socket, a device) is refused before a read of it could block. tools.zig:599-627, read_file.zig:119-372.
write_filetoolimplementedCreates a file, or replaces one that has been read in full and has not changed since. Same-directory staging, identity plus SHA-256 revalidation, atomic rename, preserved permission bits, parent fsync. An existing target is measured by stat before it is loaded and refused above the complete-read ceiling, so an enormous preimage is declined rather than allocated. The approval it asks carries the pair: the preimage it read and the text that would replace it, each escaped and bounded to 64 KiB at the permission boundary, so under XFX_TUI=1 a whole-file replacement is reviewed on the screen the full-screen TUI row describes rather than as a digest and a 160-byte preview. A path with nothing at it shows (nothing) as its before; an existing target that is not UTF-8 is named as bytes rather than rendered lossily. tools.zig:629-651, write_file.zig:1-237, file_mutation_contract.zig:566-617.
edit_filetoolimplementedReplaces exactly one occurrence of old_string. Zero or several occurrences are refused rather than guessed; an edit that changes nothing reports No changes to <path>. Same read proof, preimage bound, and replacement path as write_file. tools.zig:653-680, edit_file.zig:1-275.
create_foldertoolimplementedCreates a directory and any missing parents. An existing directory is reported as already present rather than treated as an error. tools.zig:707-729, create_folder.zig:1-352.
terminaltoolimplementedexec action only. A recognized read-only command runs as an exact argv with no shell; anything else needs an approval and then runs through the platform shell with the exact command, cwd, and environment that were fingerprinted. Operands must be relative, free of .., and must resolve inside an authorized root. Commands that compile or run project code are not on the automatic route. Bounded output, wall-clock timeout, SIGINT cancellation, process-group kill, and exit/signal reported as facts. Captured stdout and stderr are escaped for &, <, and > before they are placed inside xfx's own <stdout>/<stderr> frame, so a program cannot close the quotation and counterfeit an <exit_code> or a project-instructions tag. An admitted git runs with -c core.fsmonitor=false, and the diff, log, and show subcommands with --no-ext-diff --no-textconv, so a repository's own configured commands cannot execute on the automatic route. Durable actions are a separate deferred row. tools.zig:85-95, terminal.zig:180-232, command_effect.zig:249-355, local_executor.zig:52-73.
tool permission modestool groupimplementedask requires a real TTY approval, discloses a bounded excerpt of the change, states what "always" would grant, and denies when there is no approval channel; auto admits bounded workspace writes and a reporting-only command grammar that cannot compile or run project code; yolo skips policy and prints a warning to stderr. Above all three, and before any of them: write_file, edit_file, and create_folder refuse a target whose path passes through .git or .xfx, because those directories configure what xfx and git are then allowed to execute. The names are compared without case, since macOS's default volume is case-insensitive and .GIT reaches the same directory there. The set is explicit rather than every dot directory, so .github stays writable. Decisions mint one-use authorities that are spent before they are revalidated. Under XFX_TUI=1 the ask approval is the inline band panel rather than a line read from the terminal, with the same disclosure and the same grant scoping; every other rule on this row is unchanged. permission_gate.zig:72-121, command_admission.zig:18-149.
automatic command grammartool grouppartialDeliberately narrower than upstream's auto classifier. Reporting commands only: no && chaining, no package-manager build/test families, and no Cargo subcommand outside the alias-proof built-ins (--version, -V, --list, metadata --no-deps) -- an [alias] in a .cargo/config.toml that automatic mode may itself have written can redirect any externally implemented subcommand, so cargo fmt and cargo clippy are refused regardless of their own behaviour. Existing path operands are canonicalized and must stay inside an authorized root. Widening any of it is a review decision. command_effect.zig:16-104.
durable terminal sessions (start, read, write, wait, monitor, resize, signal, close)tool groupdeferredPost-v0.1. A session id is a reference the model holds across turns and outlives the authority that created it. No such action name appears in the advertised schema. terminal.zig:186-232.
OS command sandboxtool groupdeferredUpstream confines commands with a platform backend. xfx does not, and reports sandbox=none in status. auto bounds what xfx agrees to start, not what a started process may do. sandbox.zig.
file management (delete_file, rename_file, copy_file, file_info, open_file)tool groupdeferredPost-v0.1. tools.zig:1358-1364.
memorytool groupdeferredPost-v0.1. tools.zig:1362.
semantic_searchtool groupdeferredNeeds an embedding index. Post-v0.1. tools.zig:1363.
web (web_fetch, web_search)tool groupdeferredPost-v0.1; network egress from the agent is out of scope for v0.1. tools.zig:1365-1366.
skills (skill, install_skill)tool groupdeferredPost-v0.1. tools.zig:1368-1369.
subagenttool groupdeferredPost-v0.1. tools.zig:1370.
MCP (mcp_search_tools, mcp_select_tool, mcp_features)tool groupdeferredPost-v0.1. tools.zig:1371-1373.
ask_user_questiontool groupdeferredPost-v0.1. tools.zig:1374.
visiontool groupdeferredNeeds image input. Post-v0.1. tools.zig:1375.
read_tool_resulttool groupdeferredNeeds bounded tool-result storage. Post-v0.1. tools.zig:1376.

Providers

SurfaceKindStatusNotes and upstream evidence
Vercel AI GatewayproviderimplementedStreaming completions over rustls. Request is prompt/tools/toolChoice; the response is a bounded SSE decode that requires a canonical finish. An HTTP endpoint override is accepted only for loopback. src/builtins/gateway.zig:41, :759-765, src/core/gateway/gateway_json.zig:333-363, src/gateway/client.zig:2718-3272.
llmuxproviderimplementedStreaming completions from a local llmux daemon over the Anthropic Messages wire. Request is model/max_tokens/stream/system/messages/tools/tool_choice, with consecutive same-role messages merged and tool_result blocks leading the message that carries them. There is no thinking field: pinning it to disabled is refused by the daemon for the default model (fable answers HTTP 400, measured 2026-08-22), so xfx gets adaptive thinking and preserves the blocks it receives rather than suppressing them. The response is a bounded SSE decode -- bounded per frame, per completion, and by tracked-block count -- that requires a message_delta stop reason, refuses an unknown one, and routes every delta by block index so a text delta naming a thinking or unopened block cannot reach the answer. Every content block is also preserved verbatim in arrival order (thinking with its signature, redacted_thinking with its payload, tool_use with its parsed input, and any unknown type exactly as it arrived), because Anthropic verifies the signature when a tool continuation or a resumed conversation replays the assistant's prior reasoning -- a rebuilt-from-text assistant turn is a 400 at the next step. Reasoning is never rendered: it is preserved for the wire and no renderer reads it. Keyless: a loopback request carries no authorization and no x-api-key, because that is what the daemon accepts as the tenant local, and xfx never reads or forwards an llmux key. That is enforced rather than assumed -- the endpoint must pass the loopback-service policy (loopback host, explicit port, no path, either scheme), and the client refuses HTTP_PROXY/ALL_PROXY, so neither a remote URL nor a corporate proxy can carry a keyless prompt off the machine. Selected by the profile-only backend and llmux_url settings, which xfx setup llmux writes. An error frame inside an HTTP 200 fails the attempt where it arrives, and is replayable when its error.type is transient (overloaded_error, rate_limit_error, api_error) -- so the same upstream condition is worth the same number of attempts whether it arrives in-band here or as a 429 on the Gateway.
prompt caching and provider optionsproviderdeferredUpstream sends providerOptions, reasoning, and Anthropic cache breakpoints. src/core/gateway/gateway_json.zig:330-378.
generation usage and billing reconciliationproviderdeferredUpstream reads providerMetadata.gateway cost and generation ids. src/gateway/client.zig:2496-2560.
transport-owned retry and team routingproviderdeferredxfx's turn owns attempts and sends no team header. src/gateway/client.zig:1459-1494, :1810-1825.
VERCEL_OIDC_TOKEN credentialproviderimplementedResolved when nonblank; highest precedence. Reported by source name only.
AI_GATEWAY_API_KEY credentialproviderimplementedResolved when nonblank; second precedence. Reported by source name only.
llmux keyless loopback credentialproviderimplementedResolved from configuration alone: an llmux_url that is present and passed the loopback-service policy. Not "the daemon answered" -- resolving a credential does no I/O, or status and doctor stop being safe to run. Reported as llmux-keyless-loopback, never as a secret, and auth_refreshable is false. Provider-scoped: it is not a Gateway credential and a Gateway key is not a substitute for it, so a provider with no credential is a refusal rather than a fallback to another provider's.
fx login credentialproviderdeferredOAuth credential store. src/core/auth/auth_runtime.zig:685-700.
stored API key credentialproviderdeferredKeychain and profile-stored keys. src/core/shared/types.zig:90-96.
Codex / ChatGPT subscriptionproviderdeferredSecond provider family. src/core/shared/types.zig:90-96.

Configuration and persistence

SurfaceKindStatusNotes and upstream evidence
project settings .xfx.jsonpersistenceimplementedUpstream .fx.json. Profile-only keys are ignored with a diagnostic. config_runtime.zig:341-379, :548-576.
profile settings ~/.xfx/settings.jsonpersistenceimplementedUpstream ~/.fx/settings.json. config_runtime.zig:381-403.
exact-workspace settings entrypersistenceimplementedworkspaces["<root>"]; exact match only. config_runtime.zig:405-443.
environment overridespersistenceimplementedXFX_MODEL, XFX_PERMISSION_MODE, XFX_MAX_AGENT_STEPS. Blank values are ignored. config_runtime.zig:445-453. XFX_TUI=1 opts a bare invocation into the partial TUI (see the full-screen TUI row); any other value, and an unset one, keep the line-oriented shell. XFX_THEME fixes the TUI band's palette to exactly light or dark, case-insensitively, and suppresses the start-up background query; any other value is ignored and the terminal is asked instead (see the colored and hyperlinked TTY output row).
provider selection provider + models + backend + llmux_urlpersistenceimplementedProfile-only, like model and permission_mode: a shared repository must not be able to choose which endpoint receives a prompt, so a .xfx.json that sets any of these is dropped with the ignored-key diagnostic. provider and models are read as of this release and are profile-only like the rest, so a cloned repository still cannot choose the endpoint. provider names a provider tag directly; models maps a provider tag to that provider's model, and models[<active provider>] outranks the flat model inside one layer while the layer order still decides between layers. When provider is absent it is derived from backend; when both are present, layer order decides first — the newer layer's key wins regardless of which key it is — and provider wins over backend only on equal sources. Any two surviving readable values that differ are reported as a config check named conflicting_provider_selection with both values and the winner explained, because a machine whose profile says two different things about where prompts go is exactly the machine an operator should be told about. An unreadable value of whichever key is newest is kept and refuses every turn, quoting the key and the value; no default is substituted. Reading never rewrites: status and doctor leave the file byte-identical, and the new keys appear only when something that already writes the profile writes them -- xfx setup <provider> -- through the same staged 0600 file and rename that preserves every unrelated key, and the writer keeps backend and model in sync with them for as long as the selected provider has a value an older binary can reach; when it does not, the legacy keys are left at their previous values rather than given invented ones. backend is read case-insensitively and trimmed. llmux_url is held to the loopback-service policy, which is not the XFX_GATEWAY_URL rule: a loopback address literal (127.0.0.1 or [::1] -- the name localhost is refused, because it resolves wherever the resolver says) with an explicit port and no path, under either scheme, and never userinfo. Neither llmux client honours a proxy or follows a redirect, because the policy can only see the URL xfx names and both are ways a reply could send the request elsewhere. A remote host is refused even over https, because the request travels with nothing to authenticate it and TLS does not make it local -- that is what keeps auth=llmux-keyless-loopback true by construction. A refused value is a diagnostic rather than a string anything downstream may trust. Neither key falls back, and neither inherits. A later layer whose value is refused clears the accumulated one rather than deferring to an earlier layer, so the operator's most recent word is never silently overridden by an older one. An unreadable backend is kept as the rejected value and refuses every turn, quoting it, because guessing would send the prompt and the Gateway credential to an endpoint nobody chose; backend: llmux with no usable url refuses and names xfx setup llmux. Neither is fatal to status or doctor, which still describe a machine whose settings are broken, and doctor reports both as a provider Fail. No new environment variables. The runtime axis and every rendered field are called provider; backend is the settings key that selects it.
config diagnosticspersistenceimplementedNon-fatal; surfaced as doctor config checks. config_runtime.zig:578-593.
ask --no-savepersistenceimplementedLoad-bearing: the default records the turn, and this flag opens no store at all, so nothing is created under ~/.xfx -- not a session directory, not a manifest. It conflicts with the resume flags, because continuing a conversation while refusing to record it would fork its history in silence.
session event logpersistenceimplemented~/.xfx/sessions/<id>/events.jsonl. An assistant_message records the provider's own replayable state under two disjoint fields: raw_content is Anthropic Messages blocks and is only ever written by that wire, and responses_state is OpenAI Responses items for the wires that produce them; a turn writes at most one. wire names the wire and its authority that produced them, and is written only when there is state to replay, so a record with nothing to carry is byte-identical to one an older binary wrote. Replay is keyed by authority, not by shape: a session recorded under one wire and resumed under another drops the state -- with a one-line notice on stderr naming both wires -- and replays text and tool calls as normal. The stored items are not deleted, so a later resume back onto the original authority replays them. A record naming a wire this binary does not know drops for the same reason rather than being guessed at. Both fields are additive and absent on older records; neither needs a schema version bump. One typed JSON frame per line carrying schema version, log generation, contiguous sequence, unique event id, and timestamp. Append + fsync, then an atomically replaced manifest publishes the exact byte boundary and its SHA-256. Readers stop at the boundary, so an unpublished, malformed, or truncated crash tail is invisible; a writable open truncates it. A sequence gap, a repeated event id, a digest mismatch, a boundary past EOF, or an unsafe session id all fail closed. Exactly one writer per session: an exclusive advisory flock is held for the handle's life and a second writer is refused, and every append re-checks the log's length so a change from outside xfx is refused rather than written over. Directories are 0700, owned by the current user, with nothing granted to group or other, and are re-checked on every use -- by every read command as well as every write, and both directories rather than only sessions -- rather than only on creation; files are 0600 (Unix). xfx's own Gateway credential is never persisted: no variant of the event union carries it, and the credential source is reported by name only. Model-read content is persisted: a tool_result event stores a file's contents or a command's output verbatim, so a secret the model was asked to read is on disk as owner-only plaintext until the session is deleted, and --no-save is the only way to record nothing. session_log.zig:2185-2195, session_replay.zig:158-207, session_event.zig:169-202.
session manifest and indexpersistenceimplementedsession.json is a rebuildable projection: it summarizes the log and names the boundary it was computed from, and any disagreement between the two refuses the session rather than preferring either. The manifest is staged under a process-unique name and cleaned up by RAII, so xfx never unlinks a stage another writer owns. The listing is a projection over manifests, deterministic (newest first, ties by id) and bounded: the whole directory is sorted before the scan cap drops anything, the newest candidates survive it, and truncated says so, so last can never mean an arbitrary old session. session_projection.zig:15-43, :248-280.
session resumepersistenceimplementedask --resume <last|ID> and --resume-id <ID>. last is scoped to the current workspace and never crosses one; an exact id may be resumed from another workspace and writes an explicit durable workspace_rebound event before the turn, keeping the origin root intact. History and the recorded model preference are restored; the permission mode is not, because a --yolo turn recorded once must not become a default later. session_store_types.zig:130-141.
permission rules and grantspersistencepartialTyped and enforced in memory for one run: exact tool+target allow/deny rules, and session grants recorded when a user answers "always". A command's key is its text and its working directory, so an approval in the workspace does not carry into a --add-dir root; a mutation's key is the target's canonical absolute path, so a grant recorded in one workspace does not authorize the same relative name after an exact-id resume rebinds the session to another one. The approval prose still names the file the way the user does. An "always" answer persists across processes for the same durable session id: the approval prompt says so and names the id, and session lists every standing grant by exact tool and target rather than as a count. Configured rules are still not read from or written to settings, and glob-shaped grants are absent. permissions.zig, session_permission_state.zig.
AGENTS.md project contextpersistenceimplementedBounded instruction files from the filesystem root down to the workspace, plus a nested directory's own file admitted immediately before a tool call touches a target inside it. Each section is labelled with its file and scope; the narrowest scope renders last. At most 32 files, 64 KiB each, 256 KiB in total -- counted in model-visible bytes, so a body is budgeted after escaping and a section after its framing, and a file whose escaped form is too large is omitted rather than clipped. Omission markers are bounded by the same file cap and name the reason. Containment is decided on canonical paths, so a symlinked directory inside the workspace cannot pull in an external file -- neither its bytes nor its provenance; a link that resolves inside the workspace is ordinary layout and is delivered once, under its real scope. Rule bodies are escaped so a repository cannot close xfx's framing and write its own. CLAUDE.md is never read directly and an --add-dir root contributes nothing. Context is rediscovered after a resume rather than persisted, so editing AGENTS.md takes effect on the next turn. context.zig:284-436, :502-519.
ask --add-dir <PATH>persistenceimplementedRepeatable. Authorizes one extra directory for this turn's read tools; a path that is not a usable directory fails the turn before any request is sent. auto will not write into an added root, so reading it and changing it stay separate decisions. cli_surface.zig:391-415, workspace_access.zig:53-96.
saved additional workspace rootspersistencedeferredUpstream persists added directories and manages them from the workspace command. xfx authorizes them per invocation only, so nothing is remembered between runs. cli_surface.zig:83.
prompt historypersistenceimplementedThe band owns its line editor, so it owns a recall. Every non-blank line the composer submits is remembered -- commands beside prompts, because a command is a line the user typed and running one again is the commonest reason to reach for a recall -- newest first, capped at a hundred entries, with adjacent repeats folded into one. C-p and C-n walk the list from wherever the caret is; Up and Down are the composer's own row movement first and become the walk only at the draft's first and last visual row, so a multi-row draft stays navigable by arrow. Entering the walk captures the half-typed line and stepping forward past the newest entry hands it back, so a recall never costs what was being written; neither end wraps; an edit ends the walk and the edited text is what the next one comes back to, while a caret move does not, because reading a recalled prompt before stepping further back is what an arrow is for there. composer_history.zig:445-540. Two boundaries, both deliberate, and neither of them a qualification of the row's status. It is in memory and not on disk: no history file is written, nothing is stored under ~/.xfx, and nothing survives the process -- upstream keeps text-only snapshots for the session too, so this is the shape of the surface rather than a part of it that is missing. And the line-oriented shell still has no recall of its own: it reads lines through the terminal's canonical mode, which gives backspace, word erase and line kill and nothing else, and adding one there means owning the terminal state that shell guarantees it never changes. A recalled collapsed paste comes back as the block it stood for rather than as its summary's words, under a paste number this session has not used -- upstream's renumbering across a recall, recorded in full on the full-screen TUI row and driven by scenario 21 of .prd/06-qa-harness.md.

Output and UI

SurfaceKindStatusNotes and upstream evidence
status/doctor text rendereruiimplementedOne [surface] key=value line per fact. output_contracts.zig:410-446, :1209-1236. provider (and provider_url when the provider has a configured one, provider_rejected when it could not be read) follows model directly, because a model name means nothing without the endpoint it is asked of. provider=rejected is a fourth value alongside gateway/llmux, for a setting that named neither.
status/doctor JSON rendereruiimplementedExactly one newline-terminated document. output_contracts.zig:489-540, :1240-1285. Carries the same provider/provider_url fields, plus provider_rejected quoting a backend value xfx could not read. auth names the credential the active provider resolved -- an environment variable's name on the Gateway, llmux-keyless-loopback on llmux -- or missing when that provider has none, in which case auth_help carries the refusal: the Gateway's two variables, xfx setup llmux when no endpoint resolved, or the unreadable provider setting and its value when nothing validly chose one. auth_refreshable is false. A machine every turn refuses is never rendered as a healthy one.
sessions/session renderersuiimplementedThe same facts as text and as one JSON document. Deterministic: two reads of an unchanged store are byte-identical. Recorded text is clipped at 2000 bytes and every session-controlled value is flattened -- including tool-call names and finish_reason, which are a closed vocabulary coming from a provider but arbitrary strings coming off the disk -- so a session that read a large file cannot flood a terminal and a recorded newline cannot forge a row.
JSONL turn event streamuiimplementedassistant_delta, tool_start, tool_result, final, and error are all produced by ask --json, with exactly one terminal event per turn.
setup renderersuiimplementedxfx setup <provider> prints [setup] key=value lines -- provider, url and catalog size when the provider has them, model, why that model, the credential source, settings path, and overridden_by when a higher layer will still outrank the file -- or exactly one JSON document with the same fields under --json. Both the override warning and the credential warning go to stderr in both modes, because they are facts about the caller's shell rather than about the setup. A failure exits 1 and reports in the shape the caller asked for: a diagnostic on stderr, or one terminal error document on stdout, matching ask.
streamed assistant textuiimplementedask without --json writes only the answer to stdout, one delta at a time, and puts a failure on stderr. orchestrator.zig:4650-4655.
interactive turn noticesuiimplementedIn the shell only, each tool call is announced on stderr as [tool] <name> running and then ok or refused: <reason>, with the reason flattened to one line and clipped. ask is unchanged: its output is its answer.
full-screen TUIuipartialOpt-in and narrower than upstream's. XFX_TUI=1 on a bare xfx takes the terminal into raw mode on its normal buffer: the band, the transcript and the composer live there for the life of the session and never move, and mouse reporting is never enabled, so the terminal's own scrollback and wheel keep working. The alternate screen is an owned excursion and nothing else -- \x1b[?1049h is written only to review a change too big for the band to show, and only for as long as that question is unanswered (below); an ordinary session, however long, writes it never. Under tmux the kitty keyboard push is omitted, because sending it there breaks key input. Raw mode is an obligation rather than a feature: the entry bits (ECHO/ICANON/IEXTEN/ISIG clear, CS8, VMIN=1, VTIME=0) are asserted positively while the child runs, and the termios captured before entry is put back on exit -- onto the descriptor it was captured from, and whether or not the screen could still be written. The band is a divider, the composer, and a hint row at the bottom of the screen -- with one more row above the divider while a turn is running, saying what it is doing, and a block of rows between the two while that turn is waiting to be told whether it may change something; every frame is one write wrapped in synchronized output (?2026h/l), and what that write carries is the difference between what the terminal is holding and what it should be holding: the band keeps a shadow grid of grapheme cells, wide-cell continuations and attribute state, builds the grid the frame would leave, and emits a CUP, replacement text and an EL for the rows that changed and for nothing else -- so a keystroke costs the cell it changed and an animation phase costs the digit it turned over. A frame whose cells, window title and caret are all unchanged writes zero bytes, which is what keeps an idle session silent between the loop's 8 ms ticks. The shadow is advanced only by a write that succeeded, so a refused frame is owed again rather than believed; a /clear, a Ctrl-L and the resume after a SIGTSTP mark the screen externally damaged, and the frame after any of the three opens with the Phase-1 erase -- the rows a shrinking band gave back, then CUP to the band's top row and ED -- because a forgotten shadow is unknown rather than blank, and a diff that treated it as blank would rewrite the band's own columns and leave whatever the shell put beside them on every band row. The erase starts at the band's top row and never above it, so the terminal's own document survives a resume. A resume also re-arms the window title: the restore that hands the terminal back pops the title stack, so the title the session set is gone, and the band owes a fresh OSC 2 -- one push per time the terminal is taken and one pop per time it is given back, so a stop/resume cycle leaves the stack balanced. The optimization is judged by the screen it leaves rather than by the bytes it saves: the fault-injection build keeps the Phase-1 whole-band painter behind a compile-time seam (Fault::FullPaintReference, absent from every released binary), and scripts/smoke-tui.sh's scenario 13 drives identical facts through both painters on a real pseudoterminal and requires the final cells, attributes and caret to be equal. The terminal's window title is set -- OSC 2 ; xfx · <model>, with every control stripped out of the model label so a configured name cannot close the sequence and have the rest of itself executed -- and it is borrowed rather than taken: the mode set pushes the terminal's own title onto its stack (CSI 22 ; 2 t) and every restore path, normal and abnormal, pops it back (CSI 23 ; 2 t). The line-oriented shell sets no title and pushes nothing. What has already reached the terminal's own scrollback is never rewritten: the diff writes only at or below the band's top row, plus the rows a shrinking band gave back, and a document append still scrolls the screen with real linefeeds -- committed rows stay the terminal's. A terminal smaller than 6 rows or 20 columns is refused with a reason rather than painted over. What those rows say: the divider spans the screen, the composer shows what has been typed into it behind its prompt marker, and the hint row says what a turn would be run with -- in upstream's order, joined by upstream's separator: a call to action when the configured provider has no usable credential (a Gateway with no bearer token or a llmux with no URL -- both refuse every turn, and the row says so before a prompt is spent on one), queued 1 while a submission is waiting, the permission mode, and a compact model label that drops the provider prefix and the claude- family prefix. A refusal takes the left of it whole when one has just happened, and esc again to clear sits flush with the last column while the double-Esc gesture is armed. On a screen too narrow for all of it the segments are dropped whole, from the right, rather than clipped mid-number: the permission mode gives way in front of the model rather than the model behind it, and the right-hand warning is reserved before the left is measured, because it is about a key that is already armed. The meter upstream ends that row with is here too and is filled from a completed turn's own input_tokens over the context window the provider's catalog publishes, shown only when the session has both numbers and dropped whole rather than defaulted when it has one; what is still absent is the shortcut legend. While a turn is in flight there is one row above the divider, and only then: it says • Thinking with how long the turn has been running, or names the tool that is running instead of saying Thinking at all -- a turn that has gone quiet because a tool is taking a minute otherwise looks exactly like a turn that has gone quiet. The marker blinks every 500 ms, counted in the renderer's own 50 ms animation phases rather than on a second clock beside it, and the row asks for a frame when its text changes rather than on every phase, so a band whose row says the same thing is not repainted twenty times a second and an idle band asks for nothing at all. The row is the band's while it exists: it takes its row from the bottom of the document exactly as a composer that grew by one does, and the divider, the composer and the caret do not move when a turn starts. It is exactly the turn's lifetime: the runtime says when a turn begins and says when it ends, in order on the one channel that carries everything else it says, and the band draws the row between the two. Told rather than counted, and that is correctness rather than taste -- a prompt is accepted the moment it is typed and may then wait behind another turn for a minute (the hint row is where waiting is said, and a waiting prompt gets no row), and the place a finished turn holds is given back after its conclusion is sent, so a band counting places would announce a turn that had not started and start a clock for one that no longer exists. The clock is one turn's: a prompt queued behind a running one does not restart it, and when that turn ends the row begins again from zero for whatever turn the runtime starts next rather than carrying the finished one's elapsed time forward. A /model or a /new travels on the same channel but is not a turn, and no row appears for one. Two things named rather than implied: the elapsed clock stops while xfx is waiting for a decision, because that interval measures the person rather than the model -- the approval panel below is what pends one, and the marker goes on blinking while it is up, so a band that had simply stopped painting cannot be mistaken for a stopped clock; and the token count upstream shows beside the elapsed time is absent, because the streamed event set carries no usage -- the row says nothing about tokens rather than reporting a zero nobody measured. What is above the divider is the turn, described below. The band's size is asked of standard output, because that is the screen it is drawn on; raw mode is entered on standard input, because that is where input arrives; and a redirected session whose two ends are different terminals gets no cursor report at all, so it starts at row 1 and pushes nothing. A screen that refuses every frame for half a second of wall-clock time ends the session with the first of those errors, on a terminal the exit still puts back, rather than being retried invisibly forever; the budget is measured on a clock rather than in repaints, because the 8 ms tick is the longest a turn waits and not the shortest. The exit clears from the band's top row downward (app_lifecycle.zig:578-593), and only when a band was really painted: a session that left before its first frame -- a Ctrl-D that arrived with the cursor report -- moves no cursor and erases nothing. At launch it asks the terminal where the cursor is (CSI 6n, 100 ms deadline, row 1 if the terminal does not answer) and pushes the shell output above that row into scrollback -- moving the cursor to the bottom row first, because a linefeed scrolls a terminal only from the bottom margin and from anywhere else merely walks the cursor down -- so the band never paints over what was already on screen; a keystroke typed while the query is in flight is deferred, not swallowed. Restoration is proven one case per exit path, each on a real pty: the normal exit, SIGTERM/SIGHUP/an external SIGINT (the child dies by the signal, which is what proves the handler reset the disposition and re-raised rather than fabricating a status), and SIGTSTP/SIGCONT including a second SIGTSTP after a resume, which is what proves the stop handler was reinstalled. Every one of those is an ordinary transition rather than a race, because the owned signals are blocked across the whole of it -- from before the tcsetattr that takes the terminal until after the last handler is installed -- so a supervisor that signals xfx during startup finds a handler rather than a default disposition and a raw terminal. The signals named here are the ones a supervisor or an operator sends: with ISIG cleared a typed Ctrl-C or Ctrl-Z generates no signal at all, and arrives as a byte for the input decoder. Ctrl-C is a byte, not a signal, and it means what it means in the line-oriented shell: the first one asks the running turn to stop and says so with that shell's own notice -- and drops whatever was queued behind it, because a queue that started running a moment after the user asked everything to stop would be the surprise the band says queued 1 to prevent; a prompt typed after the interrupt is a new intention and still runs, which the interrupt is told the boundary of rather than left to guess. The one after it leaves with 130 -- the same contract, re-proven through the new input path, and disjoint from the external SIGINT above, which still kills the process by the signal. At an idle prompt the first Ctrl-C throws the draft away instead, and a second one leaves only if it comes within three seconds of the first, so two unrelated presses minutes apart do not end a session. A typed Ctrl-Z still binds nothing. Escape is a two-tap: a second one within 500 ms clears the composer, and the hint row says esc again to clear in between, because nothing else in this phase discards a draft without a key that says so. That decoder is the session's own: bytes in, keystrokes out, one machine fed in arrival order (the keystrokes the launch probe read with the cursor report included), with a lone ESC resolved as the Escape key only after 50 ms of quiet, an unfinished CSI waited for however long the terminal takes, an unknown sequence bounded at 32 bytes and reported as a keystroke with no binding rather than as a phantom Escape, ESC followed by a control byte delivered as the Escape key and that byte -- except ESC DEL, which is Alt-Backspace and deletes one word, because replaying it would make two of them two Escapes inside the double-Esc window and throw the whole draft away, and no C0 byte or control scalar ever delivered as text -- a tab included, because a control that reached the composer would reach the terminal again on submit; a tab is a named keystroke instead, which the approval panel walks its choices with and the composer has no binding for. Bracketed paste is framed: ?2004h is enabled, the bytes between ESC[200~ and ESC[201~ are content rather than keys -- so a newline inside a paste does not submit it, a 0x03 does not cancel a turn, and an escape sequence is not obeyed -- and anything over 1000 codepoints is shown as [Pasted text #N, M lines] and expanded back into the prompt when it is sent -- expanded to the text the frame made of it, which is the pasted bytes filtered, decoded as UTF-8 with anything that is not UTF-8 replaced, and with CR and CRLF line breaks normalized to LF, rather than to the bytes the terminal sent. A collapsed block is an atomic entity: it is a span of the draft rather than a name to be searched for, so exactly one run of the draft is the block and a second copy of its summary -- typed, or pasted back off the screen, before it or after it -- is only ever the words it looks like. Every motion steps over it whole (left, right, both word moves, and the two vertical moves, which land on the nearer edge rather than inside it), so the caret is never inside a summary; every delete that overlaps it at all takes the whole of it, so one backspace at its right edge, one forward delete at its left edge, a word delete or either kill removes the block rather than damaging its name. The span moves with the text around it as that text is edited. A number a block was given is never given again in one session -- a removed block's number is spent -- and a line recalled from history brings its blocks back under fresh numbers, allocated from the same checked counter a paste spends, which is what keeps two live blocks from answering to one name; at the end of that counter a paste is refused visibly and a recall hands the line back as words and says so, rather than wrapping or reusing a number. Removing a block gives its bytes back to the budget for good: what was pasted stops being sent, and typing the same words in again is typing rather than repairing. Two edges of the frame are named rather than implied. The 8 MiB budget is the prompt's, not the screen's: it is measured over the draft and every block hiding behind a summary in it, on the decoded text rather than on the bytes the terminal sent, because a draft the user can read the whole of can be standing in front of megabytes and two ceilings of 8 MiB each would be one prompt of 16. A paste that would take that sum past the budget is refused whole -- nothing enters the composer, no block is kept, and the hint row says so, because a summary standing for text that was never held would submit its own words -- and a keystroke past it is refused too, silently, like every other keystroke the budget refuses. There is no bound on how many collapsed pastes a draft may hold: the 64-block cap Phase 1 needed was a bound on time, because the old name-based bookkeeping re-read the whole draft once per block on every keystroke, and a span costs integer arithmetic instead -- an edit reads the draft no times whatever it holds, and a submit reads it once. And a pasted tab is kept in the text and painted: the wrap measures it at four cells and the painter writes four spaces, so pasted indentation reaches the model and the screen alike and the caret sits on the glyph it belongs to. Upstream's tab stops are not implemented -- four cells is a fixed run rather than a column-dependent stop, because every measurement here is of one cluster at a time. A resize is acted on after the launch, on a monotonic deadline rather than a sleep: a SIGWINCH marks a 50 ms debounce and the first signal of a burst starts it, so dragging a window edge costs one re-solve per debounce however long the drag lasts; when it comes due the terminal's size is read -- through a separate reader from the launch's, because the two want opposite things from the same refusal: a launch has no band and must solve one from some number, so a terminal that will not say its size is answered with 24x80 there, while a running session already has a band on a screen it measured, so a refusal (and a 0x0, which a pty whose size was never set answers successfully) is a reading to ignore rather than a screen to move to -- and a reading that is unchanged or that says nothing costs nothing, and otherwise the band is re-solved from the new screen -- the composer re-wrapped to the width it now has, the divider, the activity row, any pending approval panel and the hint row placed from it, the unfinished transcript line re-wrapped, and the shadow forgotten so the frame that follows is a whole band rather than a difference from a screen that no longer exists. The reflow stops at the divider: everything above it was written into the terminal's own document once and the terminal re-wrapped it when it changed size, by rules xfx does not model, so xfx repaints no committed row -- it writes only the band's own rows and the rows the band owned on the screen that was. A screen no band fits on re-solves nothing and, above all, answers nothing: a pending question is kept, not refused on the user's behalf because a window was dragged, and the band is re-solved when the screen grows again -- including back to exactly the size it left. And while xfx's row numbers are claims about a screen that may not exist, it writes nothing at all -- neither from the signal until the debounce resolves, nor for as long as the screen stays too small. A terminal answers a CUP past its last row by clamping it silently, so a frame written then lands on the bottom row on top of itself, and an append written then is a scroll whose rows enter native scrollback where nothing can take them back. Nothing is dropped by the wait: the rows the document is owed stay owed and the reasons stay pending, and the first frame on a screen that can hold a band carries both. The exit is where that wait would become a loss, so it does not wait: a shutdown with a SIGWINCH still outstanding measures the screen once, deadline or not, re-solves the band from it and writes the answer the pacer is still holding at the coordinates the screen really has -- nothing repaints a committed row later, so a session that came down mid-debounce would simply lose it. An exit with nothing outstanding measures nothing. The one case that still comes down silent is a screen that genuinely holds no band: there is nowhere to put the answer, and an append is a scroll whose rows enter native scrollback where nothing can take them back. During the launch it is acted on, because it has to be -- the cursor report and the window size are read a moment apart and the push above the band is computed from both, so a resize is measured again rather than aimed at a row that no longer exists -- and the check covers the push itself, not only the measurement: a scroll cannot be taken back, so a resize that lands while it is on the wire is compensated for by pushing again against the screen that now exists, which moves blank rows because everything above the cursor has already left. The last of three attempts is answered by pushing the whole screen into scrollback, which costs blank rows rather than a band drawn over the shell's output, and is re-checked like the others; a launch whose every attempt was contradicted while it was happening performs no further push against the degraded measurement and says so, opening the band on the freshest geometry it has rather than scrolling by a number it already knows is wrong -- the attempts that were invalidated may each have pushed already, which is safe in the one direction that matters and is why a fourth push computed from numbers known to be wrong is the thing withheld. That last case is the one where xfx may open its band over rows the shell wrote, and it takes a window being resized continuously for the whole launch to reach. A session that panics gives the terminal back as well, and only from the thread that owns it: the hook compares ThreadId against the one recorded when the terminal was taken, writes the abnormal restore pair before the default hook prints -- so the report lands on a cooked terminal instead of in a torn band -- and the process still dies nonzero. Ownership is recorded, and that hook armed, one statement before the tcsetattr that makes the terminal raw, so the startup order is mask, hook, raw, handlers, announce and no instant of it is raw with nothing behind a panic. A panic on any other thread restores nothing, and that is proven rather than asserted: a deliberate panic is taken on a second thread while the terminal is raw, and the terminal is read back still raw with its owner still holding it. Those rows, and the row for an initialization that fails on one side or the other of raw mode, need a build that fails on purpose: all of them run under the fault-injection cargo feature, which is off by default and in no shipped binary, so no environment variable can make a release fail this way. Answers are appended to the terminal's own document above the band -- CR-normalized, placed with CUP, scrolled with a literal newline -- so a row that leaves the screen is in the terminal's native scrollback and stays there after xfx exits. An append scrolls only the rows it added: a streamed answer that merely lengthens its last row rewrites that row where it already is, because scrolling once per delta would double-space the answer. Every appended row is painted on the screen before anything scrolls it off, one scroll at a time, because native scrollback is fed by what leaves the top of the screen -- an answer taller than the space above the band would otherwise push blank rows into scrollback and keep only its surviving tail, and Phase 1 never repaints a transcript, so what misses scrollback is gone. Each row is erased to the end of the line as it is written, because the scroll carries the band's own rows up into the document area and a re-wrap can make a row shorter than the one it replaces. Phase 1 repaints no transcript viewport: once a row has scrolled past the band it belongs to the terminal, and xfx never rewrites it -- which is also why an append the screen refused is dropped rather than retried, since a scroll that failed partway cannot be replayed without the risk of writing the same row into the document twice. The composer is xfx's own: arrows, Home/End, word moves, Backspace/Delete and the emacs control bindings, grapheme-aware so a ZWJ family or a combining accent moves and deletes as one unit, soft-wrapped word-aware with hanging spaces, and capped at half the content area plus one row. Bindings upstream has that this phase does not: selection, undo/redo, and the kill ring's yank -- the two kills take text out and nothing puts it back. Home, End and the two kills work on the line the newlines delimit; the arrows work on the wrapped rows, with a preferred column that survives a short row. The prompt marker is a two-cell gutter every composer row is written into, so the text is measured against the screen less that gutter and a row is never wider than the terminal; the marker itself sits on the composer's first row, which a draft scrolled inside its window does not show. The band grows and shrinks with the draft -- the divider moves, and the rows a shrinking band gives back are erased, because nothing in this phase repaints a document row; a screen that refused those erasures is given them again on the next frame rather than being recorded as clean. A draft past the cap scrolls inside the rows it has rather than taking more, and a draft with more rows than 65535 is measured and scrolled like any other: the row counts are not narrowed until a terminal row is made. What is typed past the 8 MiB budget (paste_framing.zig:16-35) is refused whole rather than truncated, and refused silently: upstream flashes the composer and this phase does not. That budget counts what the draft would expand to rather than what it shows, so a draft holding a collapsed paste stops accepting keystrokes earlier than its own length suggests (see the full-screen TUI row's paste paragraph). Ctrl-D leaves only from an empty composer and is a forward delete otherwise. Submitting is a turn. A submitted prompt runs exactly the turn ask runs -- same provider, same registry, same permission authority, same session store -- on a worker thread, while the UI thread keeps the terminal and never waits on it; what was submitted is echoed into the terminal's document, though that echo is not durable: the activity row a starting turn adds takes its row from the bottom of the document and paints over it, and Phase 1 repaints no transcript row, so a one-row prompt echo is gone by the time the turn it started is under way and a taller one loses its last row. What closes the loop visibly is the answer appended under it, not the echo. One turn runs at a time and one more prompt may wait: the hint row says queued 1 for as long as it does, which is the whole difference between a queue and a surprise. A third submission is refused on the hint row, beside the text it is about, with the draft left in the composer and nothing sent -- rather than queued invisibly or silently dropped. The queue is exactly as deep as the channel carrying it, so whether two prompts typed in one breath are both taken does not depend on when the runtime happened to pick the first one up. What stops a turn also stops the gesture: a Ctrl-C that ended one answer is forgotten when that turn ends, so the first Ctrl-C of the next turn stops that turn rather than the session. Streamed text is paced rather than dumped: deltas are queued and released at the backlog divided by a second and a half, held between 400 and 5000 bytes a second, so a provider that sends a kilobyte in one frame and nothing in the next is still read at one speed -- and once a turn ends what is left of it is aimed at 200 ms instead, up to the same ceiling. An escape sequence is released whole or not at all, because half a CSI on a terminal is not half a colour but a terminal that swallows the next character it is given; and the attributes an answer left open are re-opened on the next release, because every frame repaints the whole band and a painter that ends its rows with a reset would otherwise turn the second half of a sentence plain. The release is measured on a clock rather than in whole bytes per tick -- the remainder of one tick's worth is carried to the next -- so the rate a stream really runs at is the rate, not the rate rounded down at every one of the 125 ticks a second. Nothing is dropped and nothing grows without a bound, and the bound is a number: the loop stops taking events the moment 64 KiB is waiting -- asked between events rather than once a batch, so the overshoot is one event rather than a channel's worth -- and an answer that arrived in a single frame is divided into 64 KiB pieces at the point it becomes events, each piece waiting for its own permit like every other one, so "one event" is a size rather than whatever the provider chose. The most the terminal side ever holds is therefore 128 KiB, and the channel fills behind it and parks the runtime in its send. Dividing never cuts one of the terminal's own units in half -- not a glyph, not an escape sequence -- and it costs an ordinary answer nothing, because an ordinary delta is already smaller than a piece. The single exception to the 128 KiB is a text that begins with one indivisible unit larger than a piece, which is handed over whole rather than cut apart or stalled on: a 64 KiB "escape sequence" is not one any terminal will honour and nothing that is really one glyph is that long, and half of a sequence is printable text nobody wrote. Nothing is dropped to achieve any of it: an event is indivisible once it has been handed over, and dropping the end of an answer to respect a buffering number is the failure this is built to prevent. Every way out flushes what is left -- Phase 1 never repaints a document row, so an answer still in the queue when the band comes down would be an answer the user never gets. The one exception is /clear, which forgets it, because the rows it was going to be written on are the ones the user just asked to have erased. Two consequences worth naming: a very long answer takes longer to appear than to arrive -- 5000 bytes a second is the ceiling, and upstream's is the same -- and xfx's own lines (a tool notice, a refusal, the echo of a submitted prompt, the sentence saying why a turn ended) are written at the point the stream had reached when they happened rather than at the end of the document, so nothing overtakes the answer it belongs after. The two exceptions are the pair a Ctrl-C is answered with -- the interrupt notice and the sentence saying the queue went with it -- which are written at once, because they are about the keystroke rather than about the answer and an answer to a keystroke that waited for the stream would not be one. What a row may carry to the terminal is a colour and nothing else: a row is placed verbatim, so \x1b[2J, \x1b[?1049h or an OSC title in one would be executed rather than drawn, and everything but an SGR is dropped from a row before it is placed -- dropped rather than turned into a space, because the wrap that measured the row counted it at no cells. A colour costs no columns and is never cut in half, in the wrap and in the clip alike, from one measurement both use. This phase still emits no colour of its own: the escape bytes of an answer are turned into spaces at the channel before any of this, so the re-open has nothing to re-open until a palette gives it something. An unfinished line stops growing at 256 rows -- the rows above that are settled, because greedy wrapping can only ever move the last one, and holding them cost the square of the answer's length to display. Quitting mid-stream cancels the turn, keeps draining and painting what the worker has already produced until the turn says it is over, and then restores the terminal -- all of it under a deadline, so a runtime that has not stopped by then cannot hold the terminal either: the channel is closed under it, the join is bounded, and the session log is left readable, recording that turn as unfinished rather than pretending it concluded. A turn whose provider has stopped answering without hanging up is exactly that case, because the transport reads its cancellation between reads and a quiet socket never delivers one. ask mode asks in the band: an inline three-choice panel with the same disclosure the line shell's prompt makes -- what would happen, and exactly what "always" would grant -- answered with 1-3, the arrows or Tab and Enter, and refused by Esc or Ctrl-C. A decision xfx never received is a refusal, and every way of not receiving one lands there: Esc, Ctrl-C, a session shutting down, a UI that has gone away, and a screen too short to show the question at all -- which is refused on the user's behalf with a row saying so, rather than painted with its choices below the last row of the screen. That last one is asked of the surface the question is actually going to, once the change has chosen it: the band's panel is measured against the rows the rest of the band leaves (layout::fits_panel), and the review plane against its own screen, which it can put three answers on at every size a session can run on -- so a short window refuses a question the band cannot fit and does not refuse one the plane would have shown whole. The panel has the focus while it is up: a 1 is an answer rather than a character in the composer, the caret sits on the choice Enter would take, and a key it does not bind reaches nothing -- including Ctrl-D, which does not end a session that is holding a turn open waiting to be told what to do. It takes its rows from the bottom of the document, directly above the rule, so the divider, the composer and the caret do not move when a question appears; a composer already at its cap gives rows back one at a time rather than pushing the question off the screen. The question travels as an event and the answer on the control channel -- unbounded, and drained inside the turn -- so an answer cannot queue behind a prompt the turn will not dequeue until it ends, and a Ctrl-C typed at a panel both refuses the question and still stops the turn behind it. Esc and Ctrl-C are not the same refusal, and the panel is where the difference is easiest to miss: Esc answers this call -- no, and the turn goes on with a prompt still queued behind it -- while Ctrl-C is the interrupt it is everywhere else on this surface, so it travels as the cancellation, carries the same watermark, prints the same two sentences, stops the turn and drops what was waiting. The refusal comes back with the cancellation rather than instead of it: the prompter is the thing parked on that channel, and it answers the question no and hands the cancellation on to the loop that can act on it. A summary too long for the rows the screen can give it is cut with an ellipsis rather than silently. A change bigger than that sentence is reviewed on a screen of its own. Which surface a question takes is a property of the change rather than of the terminal: a content mutation whose before or after side is longer than the 160 bytes the summary quotes takes the alternate buffer -- an edit_file, whose pair is the two strings the model sent, and a write_file, whose pair is the file that is there and the text that would replace it -- and every other question -- a command, a directory, a change the summary already showed whole -- is answered in the band with the document still above it. A rule keyed on the screen's height would review a one-word edit on its own screen the moment somebody made their window short, and leave a hundred-kilobyte replacement in two rows of summary on a tall one. What that screen renders is bounded and made inert at the permission boundary rather than at the surface: each side is escaped and then truncated at a character boundary to at most 64 KiB, with one ellipsis inside the bound, so the review carries at most 128 KiB and nothing in it can command or reorder the terminal. One character is kept rather than named, and it is the change's own structure: a real line break survives the boundary and the channel, and the review screen turns each one into a row, so a twenty-line change is twenty rows rather than one wrapped run and the second half is reached by walking. Everything else a terminal would act on is named, and named individually: the three whitespace controls by their own spellings (\\t, \\r, and \\n in the band's one-line quotation) and every other control by its code point (\\u{001B} for an ESC), so a payload that swapped one control for another is two screens rather than one. The review screen names the bidirectional overrides the same way, since those are neither controls nor text. The split happens before anything is painted, so a break never reaches a row it did not end. The rendering is also injective, in two ways that were each a collision first: the backslash an escape spends is itself escaped, so a file whose lines really end and a file that merely contains the two characters \\ and n are two different screens; and a control is named by its own code point rather than blanked to one symbol, so a file of ESC and a file of BEL are two different screens as well. Without the first, replacing a hundred-line file with one line of literal escapes was shown as a change of nothing; without the second, so was replacing every control byte in a file with a different one. The change is shown whole under its two headings and C-p/C-n walk it, because a screen with no way past its first screenful would be showing the head of a change and calling it the change; the arrows still walk the three choices, since a key that scrolled and chose would answer by accident, and both surfaces decide which key means which answer with the same function. The plane is taken in one frame and given back in one write: entering carries 1049h, an erase and the whole surface, and leaving carries 1049l, the hidden cursor and the complete band repaint in a single write_all, so no terminal has an intermediate blank screen to present; ownership moves only after those bytes are delivered, and the exit that can ask, asks the band rather than the session: the normal shutdown reads which buffer the terminal was really left on -- a record of delivered bytes, not of intent -- and writes 1049l only if it was the alternate one, because a reset written by a session that never took that buffer would swap in, on a terminal that models one, a screen its user was not looking at. The panic hook and the SIGTERM/SIGHUP handlers cannot ask: they run where a lock may not be taken and a state may not be consulted, so they write one compile-time-constant restore that leads with 1049l unconditionally. That is the trade the two paths make in opposite directions, and it is deliberate: an exit that knows what is on the screen is precise, and an exit that cannot know is defensive -- a stray 1049l on a normal exit would be a bug, while a missing one on a crash would leave the user on a review screen belonging to a process that no longer exists. An alternate frame updates nothing the band knows about the normal buffer, so the row the exit erases from is still the row the band painted there, and the shell's own output above it survives the excursion. Upstream's amendment draft and its readiness commit gate remain absent. A write_file carries the same bounded before/after an edit does, built in the executor from the preimage it has already read a complete-read proof for and the exact text that would replace it, so the largest change this product makes is reviewed as a change rather than as a digest; a target with nothing at it shows (nothing) as its before, and one holding bytes that are not UTF-8 says what they are and how many rather than being rendered lossily as replacement characters. create_folder changes no content and still has no pair to show. Off this surface nothing changes: a run with no terminal on both ends still has no approval channel and still denies every ask-mode mutation with the refusal a pipe gets. A panic inside a turn never touches the terminal: the runtime thread neither restores it nor reports into it -- the panic arrives as data, and the UI restores, prints it once, and exits nonzero. The TUI answers the same seven slash commands the line-oriented shell does, and no others, from that shell's own classify and its SLASH_REGISTRY, so a leading / cannot mean two different things depending on which front end it was typed into: /help, /version, /quit and /clear are answered by the UI thread; /model, /new and /setup are handed to the runtime thread, because the model, the conversation and the provider they change live there -- and each of them has to change between turns rather than under one. A name that is not one of the seven is refused with the same sentence that shell refuses it with and is not sent to the model, so a mistyped command does not quietly become a prompt. One narrowing, a boundary rather than an omission: /clear erases the screen and the terminal's scrollback -- xfx's own answers live there -- then says that the conversation is kept, in place of the banner and session summary the line shell prints. A draft that is one word beginning with / also opens an inline completion menu in the same rows of the band a question would take -- never both, because a question closes a menu before it appears. The menu is a view of the composer rather than a second focus: the caret stays in the text being typed, only five keys are taken out of the stream (Up, Down, Tab, Enter, Esc), and Enter runs the line rather than taking the marked row, so no command costs two Returns. Matches are ranked name-prefix, then alias-prefix, then substring, ties broken by the order /help lists them, so the rows are a pure function of what was typed; Tab completes the marked command, with a trailing space for the one that takes an argument; Esc closes the menu without arming the composer's own double-Escape clear, and the closing survives further letters of the same word. Upstream's fuzzy matching, its categories and its argument completion are absent. Every line the composer submits is remembered, and walking back through them never costs the line being typed. A hundred entries, newest first, with adjacent repeats folded into one and commands kept beside prompts -- a command is a line the user typed, and running one again is the commonest reason to reach for a recall; a blank line is the one submission that is not recorded, because there is nothing to come back to. C-p and C-n walk the list from wherever the caret is. Up and Down are the composer's own movement first and become the walk only at the draft's first and last visual row, so a multi-row draft is still navigable by arrow -- which is why there are two key families rather than one. Entering the walk captures the half-typed line, and stepping forward past the newest entry hands it back, so a recall is not a gesture that destroys what was being written. Neither end wraps: past the oldest line is nothing, and the near end is the captured draft. An edit ends the walk and the edited text is what the next one comes back to; a caret move does not, because reading a recalled prompt before stepping further back is exactly what an arrow key is for there. Nothing is written to disk and nothing survives the process: a session recall rather than a history file, which is the boundary the prompt history row records. An entry carries the collapsed pastes its summaries named, so a recalled line comes back as the blocks rather than as words -- under fresh paste numbers, which is upstream's renumbering and is what keeps the recalled line and the draft it replaced from both answering to #1. The draft a walk stands aside carries its blocks the same way, so the half-typed line the walk comes back to is the whole of it. This phase adds no command, no tool and no canonical slash name -- it adds one alias, /exit, on the /quit row. Without it -- and for any other value of it, for every subcommand, and whenever either end is not a terminal -- xfx is the line-oriented shell of the interactive row and changes no terminal state.
notifications and status lineuipartialA status row, no notifications. The TUI's band ends in a hint row carrying, in order, a missing-credential call to action, queued N, the permission mode and a compact model label, with a right-aligned esc again to clear while that gesture is armed; segments are dropped from the right rather than allowed to overflow, and among the two state segments the model is the last to go. Upstream's context meter (Context: {used}k/{total}k {pct}%) is populated from two facts and shown only when it has both: the numerator is a completed turn's own input_tokens and the denominator is the max_context the provider's catalog publishes for the model in force, matched by id or by alias. Either can legitimately be absent -- a provider need not report usage, and the Gateway publishes no catalog to hold a window at all -- and a missing half removes the segment rather than defaulting it, because a row reading 0k/0k 0% would be reporting a measurement nobody took. So a llmux session grows a meter after its first completed turn and a Gateway session shows none. Upstream's opt-in statusline items -- session title, workspace identity, git branch -- are absent because xfx has no settings surface to turn them on with (they are off by default upstream too, settings_catalog.zig:53-55), its reasoning-effort and fast-mode markers name capabilities xfx does not model, and its desktop/terminal notifications are absent entirely. The line-oriented shell has no status row at all. config_runtime.zig:139-152, render.zig:391-460.
colored and hyperlinked TTY outputuipartialColour inside the TUI band only, and no hyperlinks. Under XFX_TUI=1 the band picks a light or dark palette at start-up -- XFX_THEME (exactly light or dark), else an OSC 11 background query with a 200 ms deadline, else COLORFGBG, else dark -- and uses 256 colours unless COLORTERM claims truecolor, which Apple Terminal is not believed about. Three rows carry it: the divider, the hint row, and a refusal on that row; the composer's own rows carry none, as upstream's do not. It does not follow a theme that changes while xfx runs, and it emits no OSC 8 hyperlinks. Every other output path is unchanged: ask, status, doctor, the JSON and JSONL renderers and the line shell are plain text, and the only control sequence they emit is the erase pair /clear writes.

Embedding surfaces

SurfaceKindStatusNotes and upstream evidence
CLI binaryembeddingimplementedThe only supported entry point.
WASM coreembeddingdeferredsrc/wasm_core_main.zig, src/wasm_term_main.zig.
N-API moduleembeddingdeferredsrc/napi_core_main.zig, sdk/NAPI.md.
Rust library crateembeddingpartialThe crate is public so tests can drive it, but its API is not stable and it is not published.