Files
fx/CONTRIBUTING.md
T
2026-10-12 01:07:23 -04:00

38 KiB

Contributing

Scope

fx is a CLI-first coding agent written in Zig.

Contributions should preserve that direction:

  • CLI-first over terminal-IDE behavior

  • explicit contracts over ad hoc strings and branches

  • permission-first security model

  • small, reviewable changes

  • honest docs and status reporting

Setup

Requirements:

  • Zig 0.17.0+

  • interactive terminal for manual shell testing

  • a model connection for model-backed flows. Custom model connections support local and remote endpoints. Vercel OAuth via fx login, macOS Keychain API keys via fx setup, AI_GATEWAY_API_KEY, and VERCEL_OIDC_TOKEN are also supported

Common commands:

zig fmt src/
zig build
zig build test
zig build run

Verification Workflow

Keep the local development loop focused: run the narrowest test that covers the changed path, build fx, and exercise the change using ./zig-out/bin/fx. The installed fx on PATH is not valid development evidence.

Once the focused checks pass, create a clean checkpoint commit, push the non-main feature branch, and open a draft PR immediately. CI runs the complete deterministic suite on Linux x86_64, which is the gate for every change. It checks formatting, the PGSO corpus, the public surface, and the compactor boundary, runs the ReleaseSafe unit tests, and builds fx once for the E2E jobs. Four duration-balanced E2E shards use checked-in weights to assign every Bun test file once, and files inside each shard run sequentially in separate Bun processes so terminal fixtures and process state cannot leak between files. A failed file receives one retry after tmux is reset, and a file that passes only on retry gets a warning annotation.

macOS checks run only when a change can behave differently on macOS: a Zig file with a macOS, BSD, or Linux code path, build.zig, the macOS signing script, the native SDK addon, an E2E file listed in tests/e2e/macos-platform-tests.json, or a shared E2E helper that reads the platform. Add the ci:macos label or run gh workflow run macos.yml --ref <branch> to request them for any other change. The macOS arm64 check passes without a macOS runner when the change does not need one.

Do not mark the draft PR ready until Build & Test, E2E (deterministic), Shellcheck, Startup Latency, macOS arm64, and the final ship gate have succeeded for the exact current commit. A result from an older commit does not count. Live model evals are separate from this gate because they require credentials and are not deterministic.

Changes to the PGSO pipeline also run the native macOS arm64 PGSO candidate workflow: scripts/pgso/ other than corpus.json, the setup-pgso action, or the PGSO workflow. Run it on any other branch with gh workflow run pgso-macos-arm64.yml --ref <branch>. That lane produces retained size, behavior, and performance evidence but does not alter any release artifact or update channel. Its pinned toolchain, local reproduction command, corpus exclusions, and failure rules are documented in scripts/pgso/README.md.

Every pull request also receives informational ReleaseSafe binary-size comparisons for Linux x86_64, Linux arm64, macOS x86_64, and macOS arm64. Each comparison builds the pull request merge commit and base commit on the same native runner, reports exact file and ELF or Mach-O section deltas, and emits a warning at increases of 52,429 bytes (0.050000 MiB) or more. The warning requests investigation but does not replace the full PGSO release gate or reject a valid feature solely for adding code. The same jobs smoke-test each release-style binary; the Linux arm64 and macOS binaries run nowhere else on a pull request.

Pull Requests

Every PR must carry exactly one label that describes its primary intent:

  • type: bug: fixes incorrect behavior

  • type: feature: adds a new user-facing capability

  • type: improvement: improves existing user-facing behavior

  • type: docs: changes documentation only

  • type: maintenance: changes internal tooling, dependencies, CI, or implementation structure without a user-facing behavior change

  • type: release: prepares or repairs a release

  • type: security: fixes or hardens a security boundary

If you can manage labels on vercel-labs/fx, which takes triage access or higher, apply the label when you open the PR. For a mixed PR, choose the label that best describes why the PR exists. If you contribute from outside the organization, you cannot add labels and do not need to: a maintainer applies the label during review, so open your PR and mark it ready as usual. Keep the title as a clean imperative sentence and do not add bracketed type prefixes such as [bug] or [improvement].

Requesting extra CI also needs access: the ci:macos label takes triage access, and gh workflow run takes write access. If you need the macOS checks, a PGSO run, or another on-request run and cannot start it yourself, ask in your PR and a maintainer will start it.

If an AI coding agent writes any of your contribution's prose, including the PR title and description, commit messages, documentation, and issues, it must use the technical-writer skill in .fx/skills/technical-writer/.

Repo Shape

  • src/main.zig: composition root only

  • src/core/: contracts, runtimes, config, sessions, permissions, MCP, skills

  • src/tools/: built-in tool implementations

  • src/ui/: terminal rendering, event loop, input, transcript

  • src/gateway/: AI Gateway client transport

  • .fx/skills/: optional fx-native workspace-level skill root

  • skills/: optional shared workspace-level skill root

Collaboration Rules

Before adding a new feature, answer these first:

  1. Which module owns the behavior?
  2. What is the typed contract?
  3. Does it need persistence?
  4. Does it need both text and JSON output?
  5. What docs and tests land with it?
  6. How is its deterministic E2E owner classified for macOS arm64 PGSO?

If that is unclear, stop and define it first.

PGSO corpus ownership

Classify every root tests/e2e/*.test.ts file in scripts/pgso/corpus.json. Put common or performance-sensitive behavior in training. Put important correctness, recovery, security, and rare behavior in verification-only. Exclude only nondeterministic, live-network, credentialed, sound-related, or harness-only coverage, and record the reason.

Tests added to an existing file inherit its classification. Reconsider that classification when a feature changes the file's product role, and remove stale entries when deleting a feature or E2E owner. Normal PR CI rejects missing, duplicate, stale, and unclassified files without running the full PGSO gate.

Configuration and State

Config precedence (highest wins):

  1. Environment variables such as FX_PROVIDER, FX_MODEL, FX_PERMISSION_MODE, and FX_MAX_AGENT_STEPS
  2. ~/.fx/settings.json → workspaces["<workspace_path>"] (profile workspace overrides)
  3. ~/.fx/settings.json top-level (profile global settings)
  4. <workspace>/.fx.json (committed project defaults)
  5. Built-in defaults

Project .fx.json accepts only repo-safe defaults: sandbox, max_agent_steps, max_tool_result_bytes, and context. Profile-owned keys such as provider, providers, models, model, effort, fast_mode, slash_menu_categories, startup_scrollback, prompt_history, statusLine, skill_match_fuzzy, first_call_tool_choice, auto_upgrade, auto_compact_percent, update_channel, permission_mode, permission, and skill_symlink_authorities are ignored from project config before their values are parsed.

skill_symlink_authorities is an array of absolute directories that symlinked skills may resolve into, such as an app bundle or /nix/store. It is read at startup, a workspace override replaces the global list, and its entries are combined with the colon-separated FX_SKILL_SYMLINK_AUTHORITIES environment variable.

Runtime state lives under ~/.fx/:

  • ~/.fx/sessions/<session-id>/session.json

  • ~/.fx/sessions/<session-id>/background/

  • ~/.fx/sessions/<session-id>/subagent/

  • ~/.fx/sessions/<session-id>/logs/

Sessions are global and portable across workspaces. Each session tracks a workspace_root that updates when resumed from a different directory.

Subagent children are internal ordinary sessions with their own ~/.fx/sessions/<child-id>/ directory and history. The parent owns one bounded subagent/children.json registry; each child carries only an immutable owner marker. Child sessions are hidden from ordinary session discovery and cannot be resumed directly. A first subagent.message creates a named persistent child for that parent; later messages continue it, and optional instructions replace only its child-specific system overlay.

Skills

There are two distinct skill categories in fx:

  • fx roots that belong to the product itself: .fx/skills, skills/, ~/.fx/skills

  • compatibility roots discovered for other agent installs: .opencode/skills, .codex/skills, .claude/skills, .agents/skills, .claw/skills, plus their global equivalents

/skills list should make that distinction visible to the user.

/skills add and /skills install install full skill directories into the profile-owned ~/.fx/skills managed root, not just SKILL.md. Workspace .fx/skills and skills/ remain discoverable project-local instructions, not managed install targets.

The interactive agent can also install skills via the install_skill tool when the user asks to install one in conversation, including pasted npx skills add ... syntax.

Slack installation testing

src/core/slack/install.zig owns workspace bot installation, local credential persistence, and explicit refresh. The web bridge contract is fixed to https://fx.sh/api/slack/install/config, /api/slack/install, and /api/slack/oauth/callback. Employee MCP authentication is separate.

Build with zig build, then run cd tests/e2e && bun test slack-install.test.ts. The fixture exercises the freshly built binary and real loopback sockets without live Slack credentials. FX_E2E_SLACK_ORIGIN accepts only an HTTP 127.0.0.1 origin with a non-privileged port, serving public metadata plus mocked /api/oauth.v2.access and /api/auth.test responses. Production uses pinned Slack endpoints. Local records bind to the bridge origin to prevent fixture commands from refreshing production credentials. FX_NO_OPEN_BROWSER=1 prints the start URL for headless operation; authorization still requires a browser on the same computer as the listener.

This E2E owner is verification-only in the PGSO corpus because it covers a rare workspace setup operation and security boundaries. Live Slack authorization and message attribution are not deterministic tests.

MCP

Native fx connections use MCP v1 initialization by default over stdio, Streamable HTTP, and deprecated 2024-11-05 HTTP+SSE. Servers using the newer 2026-07-28 discovery lifecycle opt in with FX_MCP_PROTOCOL_VERSION=2026-07-28 in their configured environment map. The SDK's host-owned client controls its own protocol negotiation. Native sessions load trusted MCP configuration from the profile:

  • ~/.fx/mcp.json

They also read Claude-compatible workspace configuration from:

  • <workspace>/.mcp.json

Project .fx.json does not define runnable MCP commands, URLs, env, or secrets. The profile file reads top-level mcp and accepts mcpServers as a compatibility alias; mcp wins when both exist, and every write uses mcp. Suspicious server-like unsupported keys produce a bounded warning and block profile mutation instead of being overwritten. The workspace file reads only top-level mcpServers, accepts command plus args, and is opened as a bounded no-follow regular file. Profile entries win native name collisions; ACP request entries win ACP name collisions without deduplicating the request array. Workspace entries are always optional and never load stored credentials. Approved workspace command, args, env, and HTTP header values expand ${VAR} and ${VAR:-default} from the fx process environment. Pending and rejected entries do not read environment values. Missing required variables leave an approved server unloaded and appear in the /mcp and /mcp list menu without exposing values.

Interactive sessions keep pending workspace servers disconnected and request project trust before any project-defined process or network effect. Pending resource, prompt, completion, and authentication commands require explicit /mcp trust approve <name> and a retry. Rejected servers remain disconnected. Choices live only in profile settings.json under the canonical workspace key, using enabledMcpjsonServers, disabledMcpjsonServers, and enableAllProjectMcpServers. Repository files cannot persist their own approval. fx ask and ACP skip pending workspace servers. Noninteractive users approve them first with fx mcp trust approve <name>; rejected servers remain disabled.

The core feature surface is Tools, Resources and Resource Templates, Prompts, Completion, pagination, cache-aware discovery, subscriptions, progress, cancellation, and form or URL elicitation. Keep modern and legacy protocol behavior in their existing version-scoped modules.

fx bounds schema size and structure before publication. It accepts schemas without $schema, the canonical JSON Schema 2020-12 declaration, and the canonical Draft 7 declaration used by legacy SDKs; other declared dialects are rejected. fx does not resolve network references or evaluate semantic schema assertions. Servers validate their tool arguments and results.

The interactive surface supports:

  • /mcp

  • /mcp list (opens the same server menu)

  • /mcp resource list <server>

  • /mcp resource templates <server>

  • /mcp resource read <server> <uri>

  • /mcp resource complete <server> <uri-template> <variable> [value]

  • /mcp prompt list <server>

  • /mcp prompt get <server> <name> [arguments-json]

  • /mcp prompt complete <server> <name> <argument> [value]

  • /mcp add <name> <command> [args...]

  • /mcp add --transport http <name> <url>

  • /mcp remove <name>

  • /mcp reload

  • /mcp auth <name> --open

  • /mcp logout <name>

  • /mcp trust approve <name>

  • /mcp trust reject <name>

  • /mcp trust approve-all

  • /mcp trust reset

  • /mcp path

The noninteractive MCP surface supports:

  • fx mcp add <name> <command> [args...]

  • fx mcp add --transport http <name> <url>

  • fx mcp auth <name>

  • fx mcp list

  • fx mcp logout <name>

  • fx mcp path

  • fx mcp remove <name>

  • fx mcp trust approve <name>

  • fx mcp trust reject <name>

  • fx mcp trust approve-all

  • fx mcp trust reset

The local form saves a stdio command. The HTTP form saves a remote Streamable HTTP endpoint. List reads effective profile and workspace configuration plus stored authentication state without connecting servers. Path prints the profile configuration path. Remove uses the same locked canonical profile writer as add. Trust updates the canonical workspace entry in profile settings. Auth and logout run the existing remote credential lifecycle. None of these commands constructs the TUI or contacts the Gateway.

The default MCP startup timeout is 30 seconds and remains overridable per server with startup_timeout_ms. Exact direct docker run stdio commands without --cidfile receive a private cidfile so fx can remove the container after shutdown or startup failure. An explicit cidfile remains user-owned.

When a stdio server closes its connection before answering initialize, for example because its process exited, the reported failure names the exit code or signal and includes a bounded, terminal-safe excerpt of the server's stderr with secrets masked. A startup timeout names the limit that ran out, plus an earlier launch's exit when there was one, and names the startup_timeout_ms key when that setting set the limit. When a server writes a stdout line that is not an MCP message, such as a banner, the failure quotes the start of that line. A startup restart runs only when it could change the outcome: a server that closed its connection at every offered protocol version is not restarted, and neither is one whose startup deadline has already passed. A server that fx stopped because of invalid output still gets its restart. The model sees the same reason when it searches a named server that is down, or when a tool call finds its server stopped and the relaunch fails.

MongoDB Atlas Managed MCP configuration service accounts use the OAuth client-credentials grant. fx does not implement that grant directly. Use MongoDB's mongodb-atlas-mcp-remote stdio wrapper with inherited MDB_MCP_API_CLIENT_ID and MDB_MCP_API_CLIENT_SECRET environment variables. The Atlas App Connection browser flow is user-delegated access and must not be treated as equivalent to configuration service-account credentials.

Remote authentication supports configured bearer tokens and OAuth credential discovery, persistence, refresh, scope challenges, and logout. Credential and private-cache identity changes invalidate prior private state. macOS persists OAuth credentials in Keychain and migrates the private profile credential file only after verified publication. If the user account has no default Keychain, macOS falls back to the same 0600 credential file used on other platforms under the 0700 profile directory. FX_DISABLE_KEYCHAIN=1 selects that portable backend explicitly for deterministic tests and local troubleshooting.

Servers are optional by default. Required startup failures block the first TUI or fx ask model request; optional failures publish a reduced, degraded capability set. Terminal fx ask completes admitted MCP discovery before its first model request. JSON and other headless asks start required servers first and defer optional servers until the turn performs an MCP operation or delegates MCP capability to a child. Server-filtered searches, selected tools, and feature operations activate only their target; a broad search activates the broader catalog. Each server owns its startup and recovery progress. Connection deadlines cover discovery, fallback, and restarts together. Interactive authentication and logout change only the affected connection. /mcp and /mcp list open the same bounded, secret-free menu, which refreshes its live health snapshot while open. Noninteractive fx mcp list renders the health snapshot to stdout.

Search and explicit selection share bounded schema publication. Definitions are checked against their runtime, connection, catalog, and credential generations before execution. Tool argument JSON must be bounded and object-shaped; semantic schema assertions belong to the server. Image results use the shared tool-result, provider, and versioned history paths. Saved native images use managed result artifacts that read_tool_result can load without repeating the original tool.

/mcp reload evaluates a replacement before publication, so invalid config or a required-server failure leaves the prior runtime callable.

ACP-provided servers are isolated to their owning ACP session. One-off and persistent subagents receive an immutable, permission-filtered view of the parent or ACP session's admitted MCP tools, resources, prompts, and completion capability. Missing, revoked, stale, or closed authority fails before transport.

ACP clients can also serve an MCP server over the ACP connection itself, as described in the MCP-over-ACP RFD, by declaring {"type":"acp","name":"...","serverId":"..."} in mcpServers. fx advertises mcpCapabilities.acp and sends each modern MCP request as an mcp/message request with a fresh logical requestId; the client answers with a result or error carrier, and timeouts or cancellation send $/cancel_request. These servers connect from the prompt worker at the start of the first turn, because discovery waits for replies that only the connection reader delivers, so a server that fails to connect appears in the model's server catalog instead of failing session setup. Request-scoped mcp/message notifications such as progress are not delivered to operations yet.

ACP Embedding

fx acp extends ACP v1 for clients that embed it. Extensions are read and written under _meta.fx. Start the server with fx acp --ultrafast or fx acp --no-ultrafast to set a process-local default request for sessions created by that server; the request still requires a model that advertises Ultra eligibility.

  • Client MCP tools stay loaded: tool schemas from servers in mcpServers are advertised on every turn within the mcp_selected_schema_bytes budget. Set _meta.fx.alwaysLoaded to false on a server entry to load it on demand instead. A model call naming a live MCP tool that is not loaded is loaded and admitted through the normal MCP permission path instead of failing.
  • Steering: a session/prompt with _meta.fx.steer set to true while a turn runs joins that turn at its next safe boundary and is replayed as a user_message_chunk whose _meta.fx.steering.requestId names the request. Its response arrives when that turn ends, with the turn's stopReason and _meta.fx.steering set to absorbed. Steering queued when a turn is cancelled answers cancelled with dropped; steering that a turn ended without reading returns an error so the client can send it again. Without the opt-in, a second prompt keeps the Prompt already in progress error. session/load replays absorbed steering in place with a null requestId. initialize reports support in agentCapabilities._meta.fx.steering.
  • Client system prompt: session/new accepts systemPrompt text blocks in append mode, as proposed in the client system prompt RFD, and initialize advertises sessionCapabilities.systemPrompt. _meta.fx.systemPrompt accepts the same blocks for SDKs that drop unknown fields. The prompt follows fx's own instructions in the system slot, is stored with the session, and is restored on session/load and session/resume. Set _meta.fx.terminal to false on initialize when fx is not presented in a terminal; fx then drops its terminal identity and rendering guidance.
  • Tool call metadata: each tool_call update carries _meta.fx.toolCall.internal, which is true for fx's own discovery and bookkeeping steps such as capability_search, mcp_select_tool, and read_tool_result. MCP tool calls add mcp.server and mcp.tool and use the server's tool title, falling back to the tool's own name. session/load replays the same metadata, including for servers that reconnect only on the next turn.
  • Session workspace: an absolute cwd on session/new, session/load, or session/resume becomes that session's workspace for file access, shell commands, project instructions, project skills, project MCP servers, and the session record. Model, provider, credentials, and permission policy stay as resolved at initialize from the launch directory. A client without a project can pass an empty directory it owns.
  • Profile MCP servers: ACP sessions use only client-supplied and approved project servers. Set _meta.fx.profileMcpServers to true on a session request to add the user's ~/.fx/mcp.json servers. Request entries win name collisions, and profile entries win over project entries.

Permissions and Auto Mode

Security is permission-first.

  • permission_mode controls baseline behavior (ask, auto, or full-access; yolo remains an alias)

  • permission config applies OpenCode-style wildcard rules

  • session always approvals are non-persistent; command approvals match the exact command while other grant categories may use patterns

  • configured denies are evaluated before saved-session rules; an exact saved-session deny can narrow a configured allow, while an exact saved-session allow can satisfy an unresolved configured ask

  • /permissions remember allow|deny <tool-name> <arguments-json> confirms and stores an exact rule only for an active saved session; /permissions lists stable rule IDs and /permissions revoke <rule-id> removes one

  • routine parsed development commands and reversible new-file creation can execute without model review after configured and saved-session policy; unknown, destructive, hidden, credential-bearing, public, and overwrite effects remain on the review or approval path

  • every unresolved auto action receives one narrow security review after configured policy, saved-session rules, grants, and deterministic safe authority; review input always contains the exact unmasked action and targets, origin and call identity, optional host-proven current-branch evidence, and bounded unmasked terminal-safe excerpts of earlier current-turn tool results. A text match between the action and prior tool output is evidence to inspect, not proof of prompt injection or malicious activity. Prepared file mutations and other static root tools omit task text. Reviewed commands, shell input, dynamic tools, and subagent actions also receive bounded unmasked canonical current, first, and recent root requests plus explicit omission counts; the reviewer may use that context only to distinguish trusted user intent from malicious or injected influence, never to judge task quality, alignment, or authorization. Assistant prose, permission feedback, compacted summaries, the pending tool group, later results, and tool or repository text never become authority. Shell input reviews also include the owned receiving session's launch command, working directory, and bounded current screen after verifying session authority, including on resume. The launch command describes startup only; screen content remains untrusted evidence and input still receives its own review

  • the reviewer returns caution only for concrete prompt injection or malicious activity; destructive, risky, external, public, remote, unrequested, or task-conflicting actions clear when they are not malicious. A clear review authorizes only the exact unchanged action; a caution, incomplete-evidence result, or unavailable review holds only that action and returns guidance without opening a human permission screen, disabling tools, or ending the turn

  • exact cautions and deterministic incomplete-evidence results are cached only for the current turn; an unavailable outcome is not cached as a security judgment, but the same exact action spends at most one unavailable review opportunity per turn and changed actions remain independently reviewable until the bounded current-turn review budget is exhausted. Each review accepts exactly one valid structured decision even with accompanying prose and may retry one malformed completion within the current attempt's deadline. A transport timeout, transient transport failure, or failed transport call is retried once with a fresh 30-second deadline; permanent transport failures, valid cautions, and cancellation are never retried. Legacy permission_request_id input is rejected without prompting

  • host-generated review holds retain their advice for the agent and transcript, but carry a saved review_feedback marker that excludes them from later security evidence, including after recovery. Old unmarked results remain untrusted evidence; never infer the marker from output text. Quoted review accusations and handling instructions as document or test data are not standalone proof of prompt injection

  • execution-memory schema 10 preserves review-feedback provenance while reading older schemas with an unmarked default. Conversation records use optional metadata for marked holds. Older builds may reject the new saved metadata or recovery checkpoints; do not downgrade an active session without preserving its files

  • the sandbox backend is configured independently; full access uses an effective backend of none without rewriting the saved sandbox setting

Do not add new sensitive tool behavior without integrating it into src/core/permissions/permissions.zig.

Writing a Resize Test

Render bugs that appear during window resize are hard to reason about because the footer is inline (hugs the transcript) rather than pinned to the terminal bottom, and the 100 ms debounce can mask ordering mistakes. The testing rig covers three layers. Pick the lowest layer that can catch the bug.

Zig unit test (fastest, runs in zig build test)

Drive TranscriptRuntime against the built-in VT emulator. Assertions are on the cell grid after a sequence of writes and resize calls.

test "my resize scenario" {
    var h = try Harness.init(std.testing.allocator, 80, 24, 4);
    defer h.deinit();

    try h.shell.initViewport(&h.metrics, 4);
    try h.shell.writeTranscript(h.alloc, &h.metrics, 1024, "hello\n", true);
    try h.flush();

    try h.driveResize(60, 20, 4, true);

    var row: std.ArrayList(u8) = .empty;
    defer row.deinit(h.alloc);
    try h.vt.rowText(1, &row);
    try std.testing.expectEqualStrings("hello               ", row.items);
}

Add it to src/ui/resize_tests.zig. See the file header for what each Harness method does.

tmux end-to-end test (real SIGWINCH, seconds per test)

For bugs that only show up with a real terminal and a real signal (timing, input integration, terminal-emulator quirks), add a scenario to tests/e2e/tui-resize.test.ts using the helpers in tmux-helpers.ts:

test("my scenario", async () => {
    session = await TmuxSession.create({ width: 120, height: 40 });
    await session.waitForText(">", 10_000);
    await session.resizeWindow(80, 30);
    const grid = await session.capturePaneGrid();
    expect(findFooter(grid)).not.toBeNull();
}, 30_000);

Tape-based test (replay a real capture)

For bugs reported by a user, have them run the built binary with an exact FX_RECORD=<path>, or use FX_DEBUG_RECORD=1 for an automatic private tape. FX_DEBUG_RECORD_SILENT_BANNER=1 hides the developer-only startup notice from the inline transcript without disabling capture; Ctrl+O still shows it. Drop the tape in tests/e2e/tapes/<name>.fxtape and assert against the built replay command:

./zig-out/bin/fx replay tests/e2e/tapes/my-bug.fxtape --golden tests/e2e/tapes/my-bug.txt

Check in the golden file and wire a regression test that re-runs fx replay in CI and diffs.

What Not To Do

  • Do not grow main.zig with leaf feature logic

  • Do not add hidden product state that only exists in the shell

  • Do not add a second execution path for the same feature without a clear reason

  • Do not document intended behavior as if it already exists

  • Do not commit generated state from .fx/, .zig-cache/, or zig-out/

  • Do not add a general alternate-screen (\x1b[?1049h/l) render path. fx is inline by design except for the three exclusive owner classes represented by AlternateScreenOwner: interactive tool-approval review, the full-transcript screen, and catalog menus. Every owner must leave or explicitly hand off the alternate buffer and restore the main grid, composer, cursor, paste, mouse, focus, and keyboard modes before resolving, cancelling, or shutting down

Releases

Releases are triggered automatically when the version in src/main.zig changes on main:

  1. Edit pub const version = "X.Y.Z"; in src/main.zig
  2. Merge to main
  3. The release workflow checks if vX.Y.Z tag exists; if not, it builds four platform binaries, creates the git tag, and publishes a GitHub Release with the binaries attached

The install script and fx upgrade fetch binaries from releases.fx.sh, backed by the public Vercel Blob CDN. No authentication or external CLI tools are required. The release workflow also publishes binaries to the CDN and updates latest.txt automatically.

After CI passes for a push to main, the dev release workflow publishes commit-addressed binaries and then updates dev.json. Dogfooders opt in with fx upgrade --channel dev; the choice is stored in their user settings and applies to manual upgrades, automatic upgrades, and the ctrl+g handoff. fx upgrade --channel stable returns to tagged releases. Dev publishing does not create tags or GitHub Releases.

Release notes are public product copy. Describe user-visible behavior, always spell the product fx, and omit contributor attribution, tracker references, repository or website work, delivery infrastructure, CI and test details, branch history, and implementation-only refactors. Use commits and pull requests as research evidence only. Changelog formatting and release-marker rules live in AGENTS.md.

Do not create tags manually. The workflow owns tag creation.

Validate release artifacts without publishing

Run Actions > Release on main with validate_only enabled. This builds all four release targets, runs macOS arm64 PGSO qualification, and uses the existing apple-signing approval to notarize both macOS targets. It does not create a tag, publish a GitHub Release, upload to the CDN, or change a channel.

The arm64 validation retains both 4 KiB and 16 KiB signature variants of the same PGSO payload for comparison. Intel retains 4 KiB signatures. Download the workflow artifacts for matched signed-binary performance checks; notarization and smoke checks alone do not establish performance equivalence. Normal release runs keep the existing signing default.

To compare the retained signatures on an isolated runner, run Actions > Benchmarks with signed_run set to the successful main validation run ID. This mode downloads already-notarized binaries and does not access Apple credentials. It records two alternating startup cohorts, an identical-control status calibration, and a separate native image-flow memory screen. Review the retained measurements before changing release signing defaults; successful measurement is not performance approval.

Benchmarks

Startup latency benchmarks run automatically on every PR and push to main via .github/workflows/bench.yml.

The workflow builds a ReleaseSafe binary, then uses hyperfine to measure wall-clock time for six paths:

Command Budget What it measures
fx (startup) 2ms Binary launch through CLI dispatch (no TTY needed)
fx help 2ms Minimal startup, pure text output
fx status --json 2ms Config read + JSON serialization
fx background --json 2ms Background record read
fx doctor --json 2ms System checks, subprocess spawns
fx sessions --json 2ms Session directory read

On PRs the check fails if any command exceeds its budget.

The table is the authoritative Linux CI contract. Non-Linux local runs report raw means for comparison but do not assign a substitute product budget because the host process and dynamic-loader floor can independently exceed 2ms. The process baseline is diagnostic only and is never subtracted.

The startup benchmark uses FX_BENCH=1, which runs through CLI dispatch and exits before TTY initialization.

To run locally:

brew install hyperfine             # macOS (one-time)
./benchmarks/startup.sh            # full run (100 iterations, builds ReleaseSafe)
./benchmarks/startup.sh --quick    # quick run (20 iterations)

CI uses --runs 100 with a reduced warmup and skips the build step because the workflow builds ReleaseSafe first. Results are written to benchmarks/results/ (gitignored).

FX_BENCH exits before the interactive shell starts, so it does not measure the time to the first frame. benchmarks/first_frame.py does: it launches fx on a pseudo-terminal, answers terminal queries like a fast emulator, and reports first byte, first frame, exit latency, CPU time, and peak RSS. Pass several binaries to compare them under the same machine load:

python3 benchmarks/first_frame.py --binary /tmp/fx-before --binary ./zig-out/bin/fx
python3 benchmarks/first_frame.py --isolated-home --cwd /tmp   # empty profile

By default it uses your own HOME and working directory, which is what users feel. It disables auto-upgrade for the measured processes and fails if a binary changes during the run. Add --no-background-reply to act like a terminal that ignores the background color query.

The libfx runtime and long-turn memory jobs run only on request. Start them by dispatching Benchmarks with libfx_runtime or long_turn_memory set, for example gh workflow run bench.yml --ref <branch> -f libfx_runtime=true.

The libfx runtime job measures cold startup, warm prompts, host-tool calls, stream throughput, and Agent cleanup. Its direct Pi comparison uses an external Zig HTTP server, Pi 0.84.4, and three alternating 100-sample rounds. On Bun, native libfx must match or beat Pi p50 and stay within 0.25 ms of Pi p95. The Node comparison is report-only because Node's bundled fetch client and Pi's dispatcher have different warm-request overhead. Both runtimes still require valid measurements, 300 samples, and exactly one inference request per prompt. Native/Wasm latency, host-tool, and resource gates remain blocking. Live model latency and bulk-stream throughput remain informational.

zig build-exe benchmarks/libfx/fake-inference-server.zig -O ReleaseSafe -femit-bin=/tmp/libfx-bench-server
node benchmarks/libfx/bench-competitive.mjs --server /tmp/libfx-bench-server --pi-root /tmp/libfx-pi --out benchmarks/results/libfx

Build the SDK artifacts and install the pinned Pi package first, as shown in .github/workflows/bench.yml. Raw per-prompt samples remain in the output directory.

Before Marking a PR Ready

Minimum checklist:

  1. Run zig fmt --check src/ and the focused tests for the changed path.
  2. Run zig build, then exercise the change with ./zig-out/bin/fx.
  3. Push the feature branch and open a draft PR immediately.
  4. Require CI, including the macOS arm64 check, and the final ship gate to pass for the exact current commit before marking the PR ready.
  5. If user-facing behavior changed, update the docs as Documentation describes.