AGENT SKILL

self

\[SYS_EXT\] You are fir. Load this whenever a request is about fir itself — what it can do, how it works, or adding/changing any part of its configuration (models, providers, credentials, skills, extensions, MCP servers, settings) — instead of guessing from config files.

License 许可
MIT
所属仓库
kfet/fir
最近更新
2026-11-13
Stars
★ 1
Forks
0

01 前言 Front Matter

name
self
builtin
true
override
true
description
\[SYS_EXT\] You are fir. Load this whenever a request is about fir itself — what it can do, how it works, or adding/changing any part of its configuration (models, providers, credentials, skills, extensions, MCP servers, settings) — instead of guessing from config files.

02 资源文件 1

03 SKILL.md 正文

fir — AI Coding Agent

You are fir. fir is a fast, portable AI coding agent. Single Go binary, no runtime dependencies. <2>0 MB static binary.

For detailed CLI flags, run fir --help. For interactive commands and keyboard shortcuts, use /help inside fir — it toggles a sticky, collapsible overlay above the editor (run /help again, or Ctrl+L, to dismiss it).

Logging & verbosity

  • -v enables Debug logging to <agent-dir>/debug.log (same as --debug; <agent-dir> defaults to ~/.config/fir).
  • -vv enables Trace logging — per micro-op events such as cache-prefix invalidations and MCP per-event chatter. -vvv and beyond clamp to -vv with a warning.
  • -V (or --version) prints the version. The old -v short alias for --version is gone; lone fir -v prints a migration note to stderr.
  • FIR_LOG_LEVEL=info|debug|trace (or a raw slog numeric level) sets the level via env; wins over FIR_DEBUG=1. CLI -v/-vv win over both.
  • debug.log is rotated in place when it grows past a size cap. Tune via settings.json "debugLog" (global or project): maxSizeMB (default 100), keep (default 20 compressed backups), compress (default true), checkEveryWrites (default 1000), checkEverySeconds (default 30). Backups are standard gzip (debug.log.1.gz, .2.gz, …) readable by gunzip/zcat. Rotation is cross-process safe via an advisory flock on debug.log.lock.

Installing fir

  • Homebrew (macOS, Linux): brew install kfet/ai/fir (auto-taps kfet/homebrew-ai; the formula is regenerated by GoReleaser on each release and pulls binaries from the kfet/fir-dist mirror, so no GitHub auth is required). To pin or roll back to a specific minor channel: brew install kfet/ai/fir@0.29 — pinned and rolling formulas both provide bin/fir, so switching between them uses brew unlink fir / brew link --overwrite fir@0.29 (the rollback flow). The tap keeps the 10 most-recent minors.
  • Install script (macOS, Linux, Raspberry Pi): curl -fsSL https://raw.githubusercontent.com/kfet/fir-dist/main/install.sh | sh
  • Self-update: fir update (pulls from kfet/fir-dist).
  • Check only: fir update -check reports the running and latest versions and exits 3 when an update is available (0 when up to date, 1 on error), so a fleet script can branch on the status. It never downloads or replaces anything. Resolution goes through github.com/kfet/distkit, the family's distribution library, which is wired in DORMANT: it resolves and reports, while fir's own stdlib updater (pkg/update/release.go) performs the actual swap — it checks the download against the release's checksums.txt (catching corrupt or truncated downloads) and refuses to install on a mismatch or missing entry. The cached 24h background check runs both resolvers and records any disagreement in <agentDir>/update-check.json (distkit_version, distkit_error), logging it at WARN; fir update -check prints the last disagreement if there was one.

Shell completion

Bash and zsh completion ship with fir. Homebrew and the install script drop them automatically; for manual setup or go install builds:

fir completion bash > ~/.local/share/bash-completion/completions/fir
fir completion zsh  > "${fpath[1]}/_fir" && compinit

The completion covers every flag and subcommand and dynamically completes --provider, --model, --extension, --skill, --session, and installed packages by shelling out to fir --list-models, fir extensions list, etc. A build-time test (cmd/fir/completion_test.go) parses args.go and fails CI if any new flag/subcommand is missing from the completion scripts.

Not to be confused with fir install <source>, which installs packages (skills/extensions/themes) into an existing fir install — see "External Packages" below.

Modes

  • Interactive (default) — full TUI with markdown rendering, streaming, slash commands, and live plan visualization (/plan to toggle)
  • Print (-p / --print) — non-interactive one-shot; process prompt and exit
  • ACP (--mode acp) — Agent Client Protocol, JSON-RPC 2.0 over stdio for IDE integrations (e.g. Zed). Supports the unstable session/list, session/resume, session/fork, and session/release methods. To curb unbounded session/sidecar growth when a relay never releases sessions, fir auto-reaps in-memory ACP sessions idle longer than --acp-session-idle-ttl (default 90m — keep above the 1h prompt-cache TTL; 0 disables) — an idle session then holds zero extension sidecars. Every session (all modes) saves its state — model, thinking, name, cwd, mode, _meta, session-scoped/client mcpServers — next to its transcript (<session>.jsonl.state.jsonl, 0600) and restores it on -c/--session//resume (see docs/session-restore.md). In ACP the sessionId is a handle bound to the current transcript, so the next prompt on a reaped sessionId — even after a fir restart — rebuilds the session in place (same ID). session/new/load/resume values override and are saved. A resume never waits for MCP servers: each is advertised from its saved tool schemas until it connects (calls to a down server return a tool error); a client replaces its servers with session/load. Unknown-session requests (prompt/release/set_model/etc.) return the typed JSON-RPC error code -32001 (session not found) so relays can transparently re-create the session. MCP data passthrough (capability agentCapabilities._meta["dev.fir.mcp/v1"]): MCP tool calls carry _meta["dev.fir.mcp/v1"] with the server, original tool name, tool definition _meta (e.g. ui.resourceUri), result _meta and structuredContent; MCP resource/resource_link result blocks are kept as ACP resource blocks; extension methods _fir/mcp/resources/read and _fir/mcp/tools/call reuse the session's MCP connections, limited to tools in the session's active tool set (see docs/acp-mcp-passthrough.md). fir does no UI work — clients render MCP Apps themselves. Mid-turn steer (capability _meta["dev.fir.steer/v1"]): _fir/session/steer {sessionId, prompt} queues content onto the running turn's steering queue (seen at the next tool boundary, turn not cancelled); with no running turn it returns -32002 and queues nothing. mode: "record" with {question, answer, forkSessionId?} instead appends a side_query custom transcript entry that is never sent to the LLM, with or without a running turn (see docs/acp-steer.md).

Configuration Hierarchy

Settings merge in order (later wins):

  1. Global config — <agent-dir>/settings.json (~/.config/fir by default; override with FIR_AGENT_DIR or fir --agent-dir <dir>, with the CLI flag winning)
  2. Project config — .fir/settings.json in project root
  3. CLI flags — override everything

All settings fields are optional. Project settings are merged on top of global settings field-by-field; nested objects merge recursively, arrays and primitives from the override win.

Global Directory (<agent-dir>/, default ~/.config/fir/)

File Purpose
settings.json Default provider, model, thinking, compaction, retry, terminal, image settings
models.json Add or override models per provider (custom ids, base URLs, headers, costs, sweScore)
provider-usage.json Usage/auth warnings: enabled, warn_levels ([80,95]), stale_after_seconds (600), refresh_grace_minutes (15), login_max_age_days (0=off), burst_points (3), burst_window_seconds (600) — burst = account-wide 5h utilization jump read from response headers
keybindings.json Custom key bindings for interactive mode
sessions/ Saved conversation sessions
skills/ User-level skills (shared across projects)
extensions/ User-level extensions (shared across projects)
packages/ Installed external packages (user scope)

Project Directory (.fir/ in project root)

Path Purpose
settings.json Project-level setting overrides
keybindings.json Project-level keybinding overrides (merged on top of global)
skills/ Project-specific skills (auto-discovered)
extensions/ Project-specific extensions (auto-discovered)
packages/ Installed external packages (project scope, via --local)
mcp.json MCP server configuration (project-level)

Project Context Files

fir reads AGENTS.md (or CLAUDE.md) from the working directory and its ancestors, automatically including them in the system prompt. This is the primary way to give fir project-specific instructions.

Authentication

Two methods:

  1. Environment variables — set <PROVIDER>_API_KEY (e.g. ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY). See the full list below.
  2. OAuth — use /login in interactive mode, or fir login <provider-id> from the shell. Supports Anthropic, Google Antigravity, OpenAI Codex, GitHub Copilot, and Poe. Credentials are persisted automatically.
  3. New OAuth providers can be added as extensions

Login subcommand

fir login <provider-id> runs the OAuth flow for a provider from the shell (no interactive TUI required) and writes credentials to <agent-dir>/auth.json. Because OAuth providers are contributed by auth extensions, fir login loads extensions before running the flow — honouring the settings.json extensions allowlist, -e/--extension, -d/--disable-extension, and --no-extensions, exactly like the main CLI. fir login list (or bare fir login) lists every provider registered after extensions load. Equivalent to the long-standing --login <provider-id> flag; both paths share the same session-bootstrap code.

Multiple accounts per provider

A provider can hold several accounts at once. Running fir login <provider-id> a second time adds another account rather than evicting the first — e.g. a personal and a work Anthropic login coexist. Accounts are stored in auth.json under composite slot keys: the bare <provider> key is the default account, and extra accounts use <provider>#<accountId>. For Anthropic the account id is a human-readable slug built from the email and organization name (e.g. anthropic#me@example.com-acme-corp, falling back to uuids only when neither is available), and the selector label shows the display name (or email) plus the org — e.g. Ada Lovelace (Acme Corp). The same user logged into two different orgs therefore yields two distinct, recognisable accounts. Older fir builds reading the file still find the default account and ignore the #account keys.

Each account appears as its own labelled group in the model selector and --list-models (e.g. "Anthropic (Claude Pro/Max) (work@x.com)"), so switching accounts live is just selecting a model under that group — no env juggling, no restart. fir login list also prints the stored accounts. Remove a single account with fir logout <provider[#account]> (or fir logout list to see slots).

Keeping an OAuth login alive on an idle machine

fir auth refresh [provider-id | provider-id#account] performs the OAuth refresh grant and nothing else — no model, no tools, no inference, no quota. It exists because fir otherwise only refreshes a token as a side effect of running a turn, so a box that sits idle (a bot host, a rarely-used fleet machine) eventually lets its refresh token expire and needs an interactive browser re-login. Put it in cron:

0 4 * * *  fir auth refresh
  • Provider defaults to anthropic; any registered OAuth provider id works. Nothing provider-specific is hardcoded — endpoints, client id and expiry normalisation come from the extension's declare_oauth_provider spec and auth_post_exchange hook.
  • Every stored account slot of that provider is refreshed, default first. One slot failing does not stop the others, and the exit status is non-zero if any failed.
  • Pass an account slot key (fir auth refresh anthropic#work@x.com) to refresh just that one account. This is the same provider#account form this command prints, and the one fir login list and fir logout accept, so a slot key pasted straight back in works. An unknown slot under a valid provider lists the provider's stored slot keys instead of refreshing anything.
  • Output is one greppable tab-separated line per slot: <slot> <label> <outcome> expires=<RFC3339> in=<duration>, where outcome is refreshed, fresh, skipped or failed.
  • A credential more than an hour from expiry is left alone rather than needlessly rotated. --within DURATION moves that guard; --force rotates regardless.

Rotation is destructive — know this before you use --force. OAuth refresh tokens rotate on every grant, and Anthropic revokes the previous access token the instant the rotation happens — it does not survive to its stated expires. fir handles this: any process resolving a key re-reads auth.json when it has changed on disk, and the request path re-reads unconditionally before reporting an auth failure, so a session that is live across a rotation picks up the new token and retries rather than dying on a 401 OAuth access token has been revoked. Never hand-write auth.json or run a refresh grant outside fir — an out-of-tree writer racing fir over a rotating credential can replay a consumed refresh token and get the entire token family revoked.

Copying the agent dir does NOT isolate a --force test. cp -a ~/.config/fir /tmp/firtest && FIR_AGENT_DIR=/tmp/firtest fir auth refresh --force looks safe and is not. The copy duplicates the file, but the refresh token inside it is the same credential upstream — forcing a grant on the copy rotates the real credential at the provider and revokes the access token every live fir session is holding. A copied agent dir is only safe for paths that spend no grant (a plain fir auth refresh that reports fresh, skipped, or an unknown-provider error). Genuinely isolated --force testing needs a separate login, not a file copy.

Secret storage (auth.storage, fir auth storage)

By default every credential (provider logins, API keys, MCP OAuth tokens) is plain text in auth.json (0600, flock-guarded). Set "auth": {"storage": "auto"} (or "keyring") in the global settings.json to move secrets into the OS secret store — macOS Keychain, Linux Secret Service (GNOME Keyring/KWallet over D-Bus), Windows Credential Manager. auth.json then keeps only metadata (type, label, expiry) plus a _fir_storage marker; secrets are one keychain item per slot under service fir. auto falls back to auth.json when no store answers (e.g. headless Linux without a session bus); keyring errors instead. Once migrated the marker is authoritative: if the store later becomes unavailable fir reports an error rather than silently writing secrets back to the file.

  • fir auth storage — show where secrets live, the setting, and whether the OS store answers.
  • fir auth storage keyring — migrate now (each secret is written and read back before the file is stripped).
  • fir auth storage file — move secrets back into auth.json and delete the keychain items (set auth.storage to file first, or the next access migrates them again).

Amazon Bedrock accounts

fir login bedrock configures one or more Bedrock accounts (these are credential configs, not OAuth flows). Three modes:

  • iam-profile — a named ~/.aws profile (SigV4). Profiles are seeded from ~/.aws/config.
  • iam-keys — explicit AWS access/secret (and optional session) keys (SigV4).
  • bearer — a Bedrock API key / bearer token (suppresses SigV4); seeded from $AWS_BEARER_TOKEN_BEDROCK if present.

With no flags it prompts interactively; non-interactive flags: --mode, --account NAME, --region, --profile, --access-key, --secret-key, --session-token, --token. Each account can carry a per-account region (which selects the regional bedrock-runtime endpoint) and per-model inference-profile ARN overrides (stored in the credential's extra.modelOverrides). Like OAuth accounts, multiple Bedrock accounts coexist and switch live via the selector; the credential type is stored as aws_iam (profile/keys) or api_key (bearer) in auth.json.

Session observation (fir observe / fir send)

Every fir session writes a JSONL transcript to disk via SessionStore. The builtin observe extension registers a sidecar at $XDG_STATE_HOME/fir/agents/<session-id>.json containing discovery metadata (pid, socket path, transcript path, cwd, status, name) and listens on a Unix socket for input injection.

fir observe and fir send are CLI verbs of the observe extension (declared via cli_verbs: in its frontmatter), dispatched cold by fir's verb-dispatcher. There is no Go subcommand for either — the entire implementation lives in pkg/resources/builtin_extensions/observe.py. See docs/design/extension-cli-verbs.md for the mechanism.

The same extension also exposes:

  • /observe and /send slash commands — usable from inside an interactive session for snapshot inspection / one-shot message injection.
  • observe_session, send_session, and stop_session AI tools — let the agent inspect, nudge, or terminate sibling sessions as part of a turn.

Slash commands and tools take snapshots (last N transcript lines) and send single messages; they do not live-tail. Use fir observe <id> from another terminal for live observation. observe_session / /observe also accept a partial-range slice (start/end tool params, or --start=/--end= flags) to page through a long transcript by line number instead of pulling the tail.

The observe extension is a builtin and loads automatically in every session — no special flags needed. Observation commands run from any other terminal:

fir observe                        # list LIVE sessions (status idle/running/error/no-model/observe-lost)
fir observe --all                  # include ended + crashed sessions
fir observe --json                 # the list as JSON (one status object per session)
fir observe <id-prefix>            # status + last 50 entries, then exit (on a TTY: follow live)
fir observe <id> -n 200            # snapshot of the last 200 entries; always exits
fir observe <id> -f                # follow live until the session ends (Ctrl-C / Ctrl-\ to stop)
fir observe <id> --status [--json] # status only: idle|running|error|no-model|observe-lost|ended|crashed,
                                   #   model, current tool, run count, last error, startup notice
                                   #   crashed = fir host dead; observe-lost = host alive but
                                   #   not observable/sendable in real time (do not kill for it)
fir observe <id> --wait [--timeout S]  # block until the current run ends (124 on timeout)
fir observe <id-prefix> --json     # raw JSONL (snapshot; with -f a raw tail)
fir observe --cwd .                # resolve by directory (symlinks resolved)
fir observe <id> --interact        # follow + pipe stdin to the session

fir send <id> 'message'            # send one message (args joined)
fir send <id> --wait 'message'     # send, block until that turn ends, print the final reply
                                   #   (exit 1 if the turn failed, 124 on --timeout S)
echo "brief" | fir send <id>       # piped stdin = ONE message (multi-line ok)
fir send <id-prefix>               # on a TTY: interactive, Enter sends each line
fir send <id> --steer|--follow ... # deliver as steer (interrupt) / followUp (queue)
fir send <id> --abort              # cancel the current turn
fir send <id> --model prov/id ...  # switch provider+model (alone, or before the message)

<id-prefix> prefix-matches against session id, session name, or basename(cwd). First-line sigils in fir send: !message → steer, +message → followUp, ~ → abort, \!/\+/\~ to escape. Ctrl-\ detaches. In interactive fir send and fir observe --interact, a typed /model provider/id line switches the session's model instead of being sent. The send_session tool takes a model parameter for the same switch. A steer lands at the next turn boundary: most tools (bash) finish first, but wait yields within ~1s (outcome=interrupted + resume handle) and pipe stops between steps.

Status comes from the core-owned session/status observable card (<transcript>.cards), which AgentSession writes in agent-loop order on every lifecycle transition (pkg/session/statuscard.go): idle, running (from prompt acceptance to agent_end; detail names the running tool), error (last run ended with a provider/auth error — the message is in error:), no-model (prompts are refused; the startup "No models available…" notice is in notice:). Detail is key: value lines (status, model, tool, runs, error, notice). Liveness additionally probes the session's input socket, so a reused pid does not resurrect a dead session.

Note: when using -e <name> flags to selectively enable extensions, include all required auth extensions too (e.g. -d auto-namer -d notify to suppress noise without blocking auth). Using -e observe alone blocks auth extensions.

For multi-pane monitoring: run fir observe <id> -f in one tmux pane and fir send <id> in another.

Session stores — reading other hosts' sessions (sessionStores)

fir writes sessions only to its own store, <agent-dir>/sessions/ (label local). Extra read-only stores — directories laid out the same way, e.g. a Syncthing copy of another host's ~/.config/fir/sessions/ — are configured in the GLOBAL settings.json (a project .fir/settings.json cannot add stores; fir warns and ignores it):

{ "sessionStores": [{ "path": "~/fir-traces/zbox", "label": "zbox" }] }

path must be absolute or ~/…; label defaults to the directory name, must match [A-Za-z0-9._-]+, be unique and not local. Stores are always read-only (readOnly: false is ignored with a warning). A missing directory is skipped, not an error; a path that overlaps fir's own <agent-dir>/sessions/ is dropped with a warning. FIR_SESSION_STORES=zbox=~/fir-traces/zbox[,label=path] replaces the setting for one run.

What sees them:

  • fir sessions list [--all|--cwd DIR] [--store L|--read-only] [--limit N] [--json] — TSV store modified id msgs path cwd name first_message, newest first, no header (--json: one object per line). Archive sessions are listed from each transcript's .meta.json only (no .meta.json, or modified in the last minute = mid-sync, → skipped); a missing cwd is decoded from the directory name (lossy: - vs /).
  • fir sessions dirs [--json] — store path readonly exists.
  • fir sessions search PATTERN [--kind bookmark|transcript] [--cwd DIR] [--store L] [--since 72h] [--limit 50] [--per-file 3] [-i] [--json] — Go regexp, bookmarks first then transcripts, newest first (ctx-* logs excluded); hit = store kind id path:line snippet. Bookmarks are fast; a whole archive's transcripts take tens of seconds.
  • /resume picker (labelled [zbox]), fir --resume <id|label:id>, ACP session/list (title prefixed [label], _meta: {"fir/store": label, "fir/readOnly": true}), ACP session/resume/load (paths inside a configured store are accepted; anything outside every store is still rejected), ACP /resume, and observe_session all=true / fir observe --all (newest 20 as archived:<label>; fir observe <id|label:id> snapshots an archived transcript).

Resuming an archive session forks it into the local store (message: "Session is in read-only store … forked"); fir never locks, repairs, writes state, cards, sidecars or metadata inside a read-only store — the check lives in the session store itself. The recall skill searches every store through fir sessions.

Driving a fir agent on another host

observe_session / send_session / stop_session see only sessions on the local machine. For a session on another host, run the CLI there over ssh (rexec) — snapshots need no TTY and always exit:

# start a named, headless-ish agent (tmux is just the process holder)
ssh HOST "tmux new -d -s w1 -c ~/proj 'fir --session-name w1'"

ssh HOST fir observe                       # what is running there, with status
ssh HOST fir observe w1 --status --json    # machine-readable status / last error
ssh HOST fir send w1 --wait --timeout 30m < brief.md  # send, wait for the turn, get the reply
ssh HOST fir observe w1 -n 40              # what it did
ssh HOST "fir send w1 '!new direction'"    # steer;  fir send w1 --abort  cancels

Scraping the tmux screen is a fallback only. For a long-lived human view: ssh -t HOST 'tmux new -As watch "fir observe <id> -f"'.

Environment Variables

{{FIR_ENV_VARS_TABLE}}

Adding Custom Models

fir's model list is the union of built-in models and anything you add. Three ways to add a model so it shows up in /model, --list-models, and is selectable as a default:

1. models.json (preferred — persistent, visible everywhere)

Drop a models.json in ~/.config/fir/ (global) or .fir/ (project). Add models under the relevant provider — they appear alongside built-ins:

{
  "providers": {
    "amazon-bedrock": {
      "models": [
        {
          "id": "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc",
          "name": "Opus 4.6 (Bedrock app profile)",
          "api": "bedrock-converse-stream",
          "reasoning": true,
          "input": ["text", "image"],
          "contextWindow": 200000,
          "maxTokens": 8192,
          "sweScore": 73.5
        }
      ]
    }
  }
}

The same file also supports per-provider overrides (baseUrl, apiKey, headers, authHeader) and per-model overrides under modelOverrides. Most model fields (cost, contextWindow, maxTokens, sweScore, compaction, serverTools, …) can be set on a custom model or supplied via modelOverrides to patch a built-in. sweScore (SWE-bench Verified %, 0–100) shows up in the /model selector and drives its sort order.

api — the wire protocol (and its silent failure mode)

api selects the wire protocol, not the vendor. Legal values are the API constants in the external module github.com/kfet/ai (types.go, at the version pinned in fir's go.mod; re-check with grep 'API =' "$(go env GOMODCACHE)"/github.com/kfet/ai@*/types.go, aliased in fir as pkg/ai/aliases.go):

openai-completions, openai-responses, azure-openai-responses, openai-codex-responses, anthropic-messages, bedrock-converse-stream, google-generative-ai, google-vertex.

Extensions may register further wire protocols at runtime (ext-api:*, ext:*).

OpenAI-compatible endpoints (OpenRouter, Groq, local servers, …) take openai-completions — not "openai", which is a provider name, not an API.

Neither mistake is caught when the config loads:

  • Unknown api — the model still appears in --list-models and /model; every request to it fails with no API provider registered for api: <value>.
  • Missing api on a provider fir has no built-in for — validation rejects the whole custom-model load (Provider X, model Y: no "api" specified. Set at provider or model level), so every custom model disappears and --provider X reports Unknown provider "X". That error text is shown only in the interactive /model selector, not by --list-models.

api can be set per provider and per model: model-level wins, then provider-level, then — for built-in providers only — the API of that provider's first built-in model. So a custom model on a built-in provider may omit api; a model on a brand-new provider may not.

Unix-socket endpoints (unix://)

A baseUrl may use the unix:// scheme to reach an HTTP endpoint over a unix domain socket, e.g. unix:///run/boxres/bifrost.sock/anthropic. Rule: the socket path is the URL path up to and including the first path segment ending in .sock (/run/boxres/bifrost.sock); the rest (/anthropic) is the HTTP path, sent with Host: localhost (so requests go to /anthropic/v1/messages). A unix:// URL with no .sock segment is an error. Works for every HTTP-based API (anthropic-messages, openai-completions, …), streaming included.

A custom provider may omit apiKey when every model's effective baseUrl is unix:// — access is controlled by socket permissions/peer credentials, and fir sends no auth header:

{ "providers": { "bifrost": {
    "baseUrl": "unix:///run/boxres/bifrost.sock/anthropic",
    "api": "anthropic-messages",
    "models": [{ "id": "claude-sonnet-4-6" }] } } }

apiKey and header values are resolved the same way: a value starting with ! is run as a shell command and its stdout used; otherwise the value is used as an env var name if that variable is set, else taken literally. There is no ${VAR} interpolation inside a string.

Provider routing: OpenRouter and Vercel AI Gateway

Aggregators route one model id to whichever backend provider they pick. Those backends are not interchangeable: they run different serving stacks, so the same id can vary in vision support, reasoning-effort handling, quantization, context window and latency between requests. If reproducibility matters, pin the backend rather than trusting the default "best available" routing.

Both aggregators are configured through the per-model compat block — either on a custom model under models, or on a built-in via modelOverrides. It is a model-level key: there is no provider-level compat. Routing is emitted only when that model's baseUrl contains openrouter.ai / ai-gateway.vercel.sh respectively, so a stray key elsewhere is inert, not an error.

{ "providers": { "openrouter": {
  "modelOverrides": {
    "moonshotai/kimi-k2.5": {
      "compat": {
        "openRouterRouting": {
          "only":  ["moonshot"],            // exclusive allow-list
          "order": ["moonshot", "together"] // try in this sequence
        }
      }
    }
  } } } }
  • only — providers exclusively allowed. This is the reproducibility knob.
  • order — ordered preference; other providers may still serve as fallback.
  • ignore — providers to skip.
  • allowFallbacks (default true) — set false to fail rather than silently route to a backend outside only/order.
  • requireParameters — only route to providers supporting every parameter in the request (the vision / reasoning-effort mismatch guard).
  • zdr — restrict to Zero Data Retention endpoints. dataCollection ("allow" | "deny") and enforceDistillableText are the related privacy filters.
  • quantizations — e.g. ["fp8"], to exclude aggressively quantized serves.
  • sort — "price" | "throughput" | "latency", or an object with by/partition.
  • maxPrice — {prompt, completion, image, audio, request} USD per MTok caps.
  • preferredMinThroughput / preferredMaxLatency — a number, or an object of p50/p75/p90/p99 cutoffs.

Key names are camelCase in fir config and are translated to OpenRouter's snake_case on the wire (allowFallbacks → allow_fallbacks). Fields whose OpenRouter schema is a union of a scalar and an object (sort, preferredMinThroughput, preferredMaxLatency) are forwarded verbatim without local validation — a malformed value is rejected by the API, not by fir.

Vercel AI Gateway uses vercelGatewayRouting with only / order only; it is emitted as providerOptions.gateway.

Discover valid provider slugs for a model with OpenRouter's endpoints API:

curl -s https://openrouter.ai/api/v1/models/moonshotai/kimi-k2.5/endpoints \
  | jq -r '.data.endpoints[] | "\(.provider_name)\t\(.context_length)\t\(.quantization)"'

Current limit: none of these are validated locally — an unknown provider slug simply routes to nothing and OpenRouter answers with an error.

Drop-in fragments: models.d/

Alongside models.json, fir reads every *.json file in a models.d/ directory next to it — ~/.config/fir/models.d/ (global) and .fir/models.d/ (project). Each fragment has the same shape as models.json. This lets tools and agents add a model or provider by dropping in a self-contained file, with no edits to a shared monolith.

Load order and merge semantics:

  • models.json is the base layer; fragments are deep-merged on top in lexical filename order — use NN- prefixes (e.g. 10-fable.json, 20-internal.json) to control precedence.
  • Within a provider: scalar fields (baseUrl, apiKey, api) and authHeader are overwritten when a later layer sets them; headers and modelOverrides maps are key-merged; the models array is concatenated and de-duplicated by id, last writer wins.
  • A fragment alone is sufficient — models.json need not exist.
  • A malformed or providers-less fragment surfaces a load error (visible via the registry error / --list-models), it is not silently skipped.
// ~/.config/fir/models.d/10-fable.json
{ "providers": { "anthropic": { "models": [
  { "id": "claude-fable-5", "name": "Claude Fable 5",
    "contextWindow": 200000, "maxTokens": 64000 } ] } } }

The catalog is read at process start, and re-read on /reload (which now also refreshes the model registry) and when the model selector opens. So a newly added fragment is picked up by /reload in a running session, by the next fir process, or — for the Poe relay's dropdown — after a relay restart (the relay snapshots the catalog once at its own startup).

Catalog overlay (fir-dist)

Below your config, above the compiled-in catalog, fir merges a catalog overlay: a models.d-shaped document published as a static file at https://raw.githubusercontent.com/kfet/fir-dist/main/catalog-v1.json. It exists so a newly-released model can reach the whole fleet as data — no binary release, no redeploy. It can add models, correct metadata, and move a provider's default model (providerDefaults). A provider default is only a product-line anchor: it resolves to the newest registered generation of the same line (e.g. claude-opus-5 → claude-opus-5-5 once that model is registered), keeping prefixes, variants and -preview tags, skipping dated snapshots. Explicit pins (defaultModel, --model) are not advanced.

Full precedence, lowest to highest:

compiled-in catalog  <  fir-dist overlay  <  models.json  <  models.d/*.json

Your config always wins. The overlay may not set apiKey, baseUrl, headers or authHeader at any level (provider, model, or modelOverrides) — endpoint and auth stay under operator control, so a typo in a published catalog can never redirect the fleet's credentials. It follows that the overlay can only add models to providers fir already knows about.

  • Fetched at startup and then every hour for the life of the session, cached at <agent-dir>/cache/catalog-v1.json; a changed catalog is hot-applied, so even a bot process that has been up for weeks follows within the TTL. A binary also embeds a snapshot, so fir works fully offline; every failure (no network, 404, malformed JSON, unknown schema version) is soft and logged at debug, never a degradation.
  • Force a refresh with /reload, which refetches ignoring the TTL and hot-applies a changed catalog to the running session.
  • fir --list-models --verbose prints each model's origin (builtin / overlay / user:models.json / user:models.d/<file>) so you can see which layer won.

Client version pins (clientVersions). The overlay also carries a small, closed set of third-party client version scalars — currently just clientVersions.claudeCode, the Claude Code CLI version the anthropic-auth extension advertises as claude-cli/<v> (external, cli). Anthropic gates new models on it, so shipping it as data means a gated model and the version pin it requires arrive together in one document, instead of costing a one-line binary release.

  • The extension holds no version literal: it declares the template claude-cli/{clientVersion.claudeCode} (external, cli) and fir expands {clientVersion.<key>} in header values only (never names, never URLs) at token-request time and after auth/modify_models. Hot: a published change takes effect within a TTL with no restart.
  • Values must match ^[0-9]+(\.[0-9]+){0,3}$ and be ≤ 32 bytes, so nothing free-form can ride in.
  • The value compiled into the binary is a floor: a published document can only ever advance the pin, never roll a host below what it shipped with.
  • To read what a session is advertising: /reload names it in its status line (… (claude-code 2.1.280)); the committed value is clientVersions.claudeCode in pkg/models/catalog-v1.json.
  • The nightly model-watch job reads npm dist-tags.latest for @anthropic-ai/claude-code and proposes a forward-only bump on the same rolling PR as new models; a moved pin alone is enough to open one.
  • Detecting that a bump is needed. When an OAuth Anthropic request is rejected with the vendor's gate sentence (Claude Code X does not support this model; version Y or newer is required), the error is rewritten to name the pin, its source (overlay vs embedded floor) and the remedy, and a client-version-gate record (pin, pinSource, required, model, host, ts, vendor error) is appended to ~/.config/fir/doctor.jsonl — doctor_query pattern=client-version-gate lists them. Every session start prints a one-line warning while any record is unresolved; it clears itself once the effective pin moves strictly past the rejected one (no manual step). fir doctor client-version-gates prints the unresolved ones (nothing when clear); fleet converge publishes that as ~/sync/shared/fleet/status/<host>.gates.

Escape hatches (env vars):

Variable Effect
FIR_NO_CATALOG_OVERLAY=1 Disable overlay fetching. The embedded snapshot still applies — this pins a host to the catalog its binary shipped with.
FIR_CATALOG_OVERLAY_URL=<url> Fetch the overlay from somewhere else (staging file, local server).

Publishing: edit pkg/models/catalog-v1.json in the fir repo and merge to main; the publish-catalog workflow pushes it to fir-dist. To roll a catalog change back, re-publish the old content with a fresh generatedAt — a binary loads whichever of its embedded snapshot and its cache is newer, so reverting the commit alone can be a no-op.

2. Environment variables (Claude Code parity)

Set CLAUDE_CODE_USE_BEDROCK=1 and ANTHROPIC_MODEL=<id-or-arn> (plus standard AWS auth — AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, optionally AWS_REGION). At startup fir registers the model under amazon-bedrock automatically so it appears in /model. Explicit --provider/--model flags still win.

ANTHROPIC_MODEL accepts a regular Bedrock model id (e.g. us.anthropic.claude-opus-4-6-v1) or a full Bedrock ARN (e.g. arn:aws:bedrock:us-east-1:123:application-inference-profile/abc).

3. CLI one-off

fir --provider amazon-bedrock --model <id-or-arn> works without any config — fir clones a sibling Bedrock model, substitutes the id, and routes through ConverseStream. Not persisted; not shown in /model until restart with models.json or env vars.

Extensions

Extensions are standalone scripts (Python, shell, etc.) in .fir/extensions/ (project) or <agent-dir>/extensions/ (global). They communicate with fir over JSON-RPC 2.0 on stdio and can register custom tools, slash commands, and event handlers.

A Python SDK is provided (fir_ext.py). No code changes needed to add an extension — just drop a script in the directory.

Disable an extension with chmod -x; a renamed file still loads.

Control loading via settings.json "extensions" allowlist, --extension/-e flags, or --no-extensions.

Extensions can optionally self-restrict by mode using script comment frontmatter (# modes: ...), e.g. tui, acp, or text. Mark an extension # explicit: true to make it opt-in only — it stays discoverable (so fir -e <name> and listings find it) but is not auto-loaded; it runs only when named in the allowlist. The shipped demo and hello example extensions use this.

Builtin extensions (notify, tmuxspinner, plan_nudger, handoff_nudger, etc.) are embedded in the binary and auto-discovered at lowest priority. Use fir extensions to list them and fir extensions install <name> to extract one for customisation.

handoff-nudger watches context usage after each turn and prepends a [SYS_EXT] note telling you to wrap up and self_handoff — auto-compaction only fires at 70% and is lossy, while cache-read cost grows linearly with conversation length. Two triggers: the ceiling, the lower of atTokens (500000) / atPercent (65) of the window; and a cold prompt cache — 30% of that ceiling (floored at 100000) once the session has been idle past idleMinutes (65), since the provider's 1h cache has expired by then and every further turn pays full input on the whole prefix. Tune or disable it in handoff-nudger.json (atTokens, atPercent, idleMinutes, nudgeEvery, off) in .fir/ or ~/.config/fir/.

Context provenance (ctx) — while the builtin ctx extension runs, fir core logs every chunk of each provider request to ctx-<session-id>.jsonl next to the transcript (kind:src rows {turn, addr, source, owner, tokens, hash} on first sight; one kind:turn row per call with model, tokens and present addrs; tokens ≈ bytes/4). Sources: system prompt, AGENTS.md, SYS_EXT section, SYS_EXT, skills index, tool schema, lesson, skill read, tool result, user msg. Chunks ≥100 tokens (and every lesson) carry a stable [ctx:<5-hex>] tag (tool results: [ctx: …] meta) on the provider copy only — history is untouched and tags are content-derived, so caches hold. The model may call ctx_used(addrs, why?) to log chunks it used (kind:cite rows). The ctx observable card shows tokens · sources · cited (observe_session ext=ctx: cited, costliest idle, duplicates). /ctx [model] reports across sessions per model sorted by cost. After reeval_turns (default 300; ctx.json or FIR_CTX_REEVAL_TURNS, 0 = off) new turns per model it injects a review prompt asking for trim/dedupe proposals as a diff — never applied; pinned patterns (default system prompt, auth, safety, security, format, copyright) are marked non-trimmable; per-model scores in the global ctx-state.json (never the project .fir/). Disable with --extension/disabled-extensions like any extension.

Extension sections — each extension may own one persistent section of standing context (ctx.set_section(text) / ctx.clear_section()), stored by fir at <agent-dir>/sections/<ext-name>.md. Fir reads that directory itself before the first turn (so fir -p sees sections without waiting on extension events) and injects the sections of installed, enabled and trusted extensions as one [SYS_EXT sections] message right after the system prompt (cache breakpoint after it; no timestamps, byte-stable). Mid-session changes are re-injected in full as [SYS_EXT section=<name> vN replaces vM] on the next turn. Caps: ~500 tokens per section, ~2000 total; over-cap writes are rejected, never truncated. Disabled extensions keep their file (not emitted); uninstalled extensions' sections are deleted at startup. /sections shows what is injected. The builtin mood extension publishes its lessons this way. Fir core also owns one per-session section, [section=mcp] (never written to disk): one line per configured MCP server that is unhealthy (needs_auth, token_expired, error, disabled) with the fir command that fixes it, e.g. MCP server slack: configured, NOT AUTHENTICATED, tools unavailable -> fir command: /mcp login slack. It is empty when all servers are healthy; the first turn waits up to 5s for initial MCP connects so it reflects real state, and changes are re-injected next turn.

Extensions can be reloaded via /reload; there is no automatic file watching. Reloading extensions rebuilds the system prompt, which might impact prompt cacheing.

Extensions can also register top-level CLI verbs via the cli_verbs: frontmatter key — fir <verb> [args...] spawns the extension cold (no session) and routes via a cli_invoke JSON-RPC request. The extension drives fir's TTY through host.println()/host.readline() etc. Built-in subcommands cannot be shadowed; verb collisions are a startup error. fir observe and fir send are the first users; see docs/design/extension-cli-verbs.md and docs/extension-protocol.md § CLI Verbs.

Skills

Skills are Markdown instruction files at .fir/skills/<name>/SKILL.md with YAML frontmatter. They provide specialized workflows the agent can follow. Skills are auto-discovered from project, user, and builtin directories.

Use /skills to list loaded skills, /reload to pick up changes. Use fir skills to list all skills, fir skills show <name> (or shorthand fir skills <name>) to inspect one — flags --full to dump the SKILL.md body, --path to print only the file path — and fir skills install <name> to extract a builtin skill for customisation.

Skills can also be invoked directly from the CLI: fir /<skill-name> [task...] rewrites into an initial agent message that points at the named skill and supplies the rest of the positional arguments as the task body. Composes with all flags (-p, --model, -c, …). Bash and zsh completion enumerate /<skill> candidates dynamically from fir skills list.

fir /<name> also runs slash commands: if no skill matches, the name is resolved against the built-in slash commands (/changelog, /session, /mcp, /model X, /theme, /skills, /plan, …) and extension-provided commands, and dispatched exactly as if typed at the interactive prompt. Resolution order is skill first (back-compat), then command. Builtin slash commands are declared once in pkg/slashcmd and shared by the TUI and ACP (each marked mode-only where it is, e.g. TUI selectors like /theme, ACP's /continue); /help lists the ones available in the current mode. fir /<name> is TUI-bound, so in headless modes (-p, --output-mode json, ACP) they fail fast with command /x requires interactive mode rather than being sent to the model.

External Packages

Install/manage external packages (git repos or local paths) that contribute skills, extensions, and themes:

fir install github.com/user/fir-pack        # install to user scope (<agent-dir>/packages/)
fir install ./local/path --local            # install to project scope (.fir/packages/)
fir uninstall github.com/user/fir-pack      # remove a package
fir packages list                           # list installed packages (source, scope, skill/ext counts, path)
fir packages update [source]                # pull latest for one or all packages

Packages are stored in settings.json under "packages". Each entry is a string ("github.com/user/repo") or an object with "source" and optional per-type filters. Installed package skills, extensions, and themes are automatically loaded.

Agent Plugins. A package whose root has plugin.json is loaded as an Agent Plugin (v1.0.0) instead: plugin.json is validated (invalid → the whole plugin contributes nothing), skills come only from skills/*/SKILL.md (invalid ones skipped), and <plugin>/mcp.json servers join MCP config as the lowest layer (user/project entries of the same name win, with a warning). Stdio servers run with cwd = plugin root and get PLUGIN_ROOT and PLUGIN_DATA (<agent-dir>/plugin-data/<name>-<source-hash>/, or .fir/plugin-data/… for --local; created only for plugins with stdio servers; kept across updates). fir.json and auto-discovery are ignored for plugins. Diagnostics print at fir install and in startup MCP warnings. fir reserves the client namespace dev.fir (no contents defined). User guide: docs/agent-plugins.md. Code: pkg/agentplugin/.

Resource Lookup Paths

Skills and themes are discovered from multiple locations. Each loaded resource is tagged with an origin that identifies where it came from:

Origin Where
builtin embedded in the binary
user <agent-dir>/skills/ (and equivalents for themes)
project <cwd>/.fir/skills/
pkg:<source> installed package, e.g. pkg:github.com/kfet/foo
path:<basename> path supplied via --skill <path> or the "skills" settings array

Origins are surfaced via the <name>__<id> form below; they are not hierarchical "wins one, loses another" priorities.

Coexistence and Disambiguation

Same-named skills coexist by default. A user skill called release and a package skill called release will both load. Each gets a unique ID of the form <sanitized-origin>__<name>, e.g. user__release and pkg_github_com_kfet_release_tools__release (every character outside [a-z0-9_] in the origin is replaced with _, MCP-tool-name style).

The agent references skills by bare name when unique, and by full ID when ambiguous. The <available_skills> block in the system prompt automatically switches to the disambiguated form when a name conflict exists, and notes this to the agent.

fir skills list shows the ID column and tags rows with [ambiguous] or [overrides …]. fir skills show <name-or-id> accepts either form; if a bare name is ambiguous it prints the candidate IDs and exits non-zero. Slash invocations (fir /<name>) follow the same rule.

Overriding Another Skill

To replace another same-named skill instead of coexisting with it, add override: to your skill's frontmatter:

---
name: release
description: My customised release flow
override: true                  # replace any other skill named "release"
---

Two forms are supported:

  • override: true — your skill replaces every other skill with the same bare name. Use this for the common case ("I want my release to be the release skill"). When two skills both claim override: true, the highest-precedence origin wins (user > project > path > pkg > builtin), the loser stays loaded under its disambiguated ID, and a warning is emitted.

  • override: <full-id> — your skill replaces one specific target. Useful when you want to coexist with some same-named variants but shadow one in particular:

    override: pkg_github_com_kfet_release_tools__release
    

    The origin portion may be written either as the raw form (pkg:github.com/…) or the sanitized form (pkg_github_com_…); both are accepted.

Diagnostics emitted at load time:

  • duplicate-name (info) — same-named skills coexist; reference by ID.
  • shadowed (warning) — a skill was suppressed by an override.
  • override-conflict (warning) — multiple skills claimed override: true for the same name; the highest-precedence one won.

The same scheme applies to extensions and themes (rolling out; skills are first).

Settings Path Resolution

Paths in "skills", "themes", and "extensionPaths" settings arrays support three forms:

Form Example Resolves to
Absolute "/opt/shared/skills" /opt/shared/skills
Home-relative "~/my-skills" $HOME/my-skills
Relative "skills" $CWD/skills (resolved at startup)

Relative paths are resolved against the current working directory, not against the settings file. This is intentional — it makes relative paths portable across projects:

// In ~/.config/fir/settings.json (global):
{
  "skills": ["skills"],       // → finds ./skills/ in any project that has one
  "extensionPaths": ["extensions"]  // → finds ./extensions/ in any project
}

This lets you establish a convention (e.g. "every project keeps skills in ./skills/") and have fir discover them automatically without per-project configuration. Paths that don't exist are silently skipped.

For project-specific settings (.fir/settings.json), relative paths also resolve against cwd (the project root), which is the natural expectation.

Background jobs in the bash tool

Background jobs started by the bash tool (cmd &, nohup cmd &, (sleep N; cmd) &) keep running after the call returns. Output goes to an in-memory pipe, not to files. The result gives a job id. bash job=<id> returns the job's status and the output since the last read, from a 1MB ring buffer; job=<id>, kill=true stops it; jobs=true lists jobs. An exited job is forgotten once it has been read. Timeout or abort kills the whole group. At most 10 background jobs run at once; a call that would start an 11th fails with an error listing the running jobs. Surviving jobs are killed (SIGTERM, then SIGKILL) when the fir process exits or before /reexec, and their pipes are closed. If fir crashes, the kernel closes the read ends and the jobs die of SIGPIPE on their next write. Jobs do not outlive the session, so for a delayed action wait inside the session, or use watcher_add.

Remote hosts (remote extension)

The builtin remote extension gives the agent six tools for working over ssh, so remote work never needs hand-written ssh one-liners inside Bash:

  • rexec(host, command, timeout_s, cwd, detach) — run a shell script on a remote host. The script is shipped on ssh stdin to bash -l -s, never as ssh host "bash -lc '...'", so nothing is re-split by an intermediate shell and no escaping is needed. The timeout_s bound is a one-line bash supervisor passed as the ssh command — it backgrounds that bash -l -s under job control (set -m), so the command leads its own process group (TERM then KILL on expiry, exit 124) while still reading your script straight off the ssh channel. Not GNU timeout, which does not exist on macOS. detach=True runs it under systemd-run --user --collect (fallbacks: double-forked setsid, then set -m + nohup for macOS) on the remote box and returns a job id.
  • rjob(host, id, action) — status / log / tail / kill for a detached job. State lives entirely in ~/.cache/fir/rjobs/<id>.{log,rc,pid} on the remote host — there is no local registry to drift.
  • rput / rget — scp file transfer sharing the same flags and result envelope.
  • rtmux(host, action, ...) — ls | new | send | cap | kill. tmux on the remote host is the PTY; every action is a stateless short exec (no held ssh -tt). cap returns unchanged: true when the pane is byte-identical to the last capture, so polling is cheap.
  • rhosts(probe) — list Host stanzas from ~/.ssh/config (Includes followed), optionally with a parallel reachability sweep.

All connection flags (BatchMode, ConnectTimeout, ServerAliveInterval, and a ControlMaster mux at ~/.ssh/fir-cm-%C with ControlPersist=120) are owned by the extension. Hosts come from ~/.ssh/config; there is no fir-side host registry. Every tool returns a discriminated envelope — outcome is one of ok | nonzero_exit | timeout | unreachable | auth_failed | no_tmux | no_target, with stdout_bytes always explicit. Only transport/target failures are tool errors; a remote command exiting nonzero is ordinary signal.

Self-host detection. A fleet agent frequently asks for the box it is already running on by alias, and ssh loopback fails with Permission denied (publickey) because a host's key is not in its own authorized_keys. rexec / rjob / rput / rget detect this and run locally instead, under an sshd-shaped scrubbed environment ($HOME cwd, bare PATH, no TMUX/FIR_*/SSH_AUTH_SOCK, same login shell, same timeout supervisor, plus the XDG_RUNTIME_DIR that pam_systemd would set so detach=True still gets a systemd-run --user unit). The envelope then carries local: true, and rhosts marks the entry self: true. The test is not a hostname comparison: ssh -G resolves the target, a ProxyCommand/ProxyJump, a non-22 port, a different target user or an ip_nonlocal_bind kernel all bail straight back to ssh, and otherwise each resolved address is bind()-tested — an address binds only if it is configured on a local interface. Pass via_ssh=True (or set FIR_REMOTE_FORCE_SSH=1) when the ssh hop itself is what you are testing. rtmux never runs locally: it refuses a self-host outright, because send-keys into the local tmux server can land in the agent's own pane — use the tmux-driver skill there.

These tools make teleoperation cheap, which is a trap: for substantial work on another host, rput a brief and rtmux new an agent over there rather than driving the job one call at a time.

Key Concepts

  • Sessions — conversations are persisted and can be continued (-c) or resumed (-r). Sessions form a tree; double-Escape or /tree navigates branches. /session (or Ctrl+S) toggles a sticky, collapsible session-info overlay above the editor (version, IDs, message/token stats, enabled extensions); toggle it again, or Ctrl+L, to dismiss it without leaving anything in the transcript. Use /new [prompt] to start a fresh session, optionally with an initial prompt that is submitted immediately. /new (and Ctrl+N) drop any queued messages and say so ("Discarded N queued messages"); a self_handoff instead carries them into the new session, delivered after the handoff prompt — preceded by any user message the old session had not yet answered ("N unanswered messages carried into the new session"). Resuming a file (non-interactive): fir --resume <file.jsonl> -p '...' appends the turn to that file (a value is taken without --at only when it is an existing file or ends in .jsonl). Forking at an entry (non-interactive): fir --resume <session> --at <entry-id> (or --session <path> --at <entry-id>) is /resume + /tree to that entry in a new process: it writes a child session (header parentSession points at the source; recorded invocation is inherited) holding the root→entry path and continues there; the parent file is never written. <session> is a path, a file name, or a session id fragment. An entry that leaves an assistant tool call without its result is rejected. The child's history is byte-identical to the parent's prefix, so on the same model/tools the Anthropic prompt cache hits. A running agent learns its own session.file, session.leafId and session.forkAt (the newest entry --at accepts — the leaf, unless the leaf is mid tool call) from agent_introspect. ACP: session/fork (capability sessionCapabilities.fork) forks at the parent's forkable leaf, or at _meta.at: "<entry-id>" (advertised as fork._meta.at: true); the response's sessionId names the child. Each session/prompt response carries _meta.leafId (the turn's leaf; advertised as promptCapabilities._meta.leafId, omitted if mid tool call) — pass it as _meta.at to fork exactly after that turn. Legacy fir-only: "at" on session/load/session/resume (spec route: _meta.at, child in _meta.sessionId). ACP /resume <n|path> --at <id> does the same in-session. Subagent with context: fork yourself. Default way to start a subagent that needs this session's context (instead of a fresh session or an aside delegate that re-explores):

    1. agent_introspect → session.file, session.forkAt.
    2. In the SAME cwd on the SAME model: fir --resume <file> --at <forkAt> --model <same> -p '<task; return a short summary>' (ACP: session/fork).
    3. Use only the child's summary; the parent file is untouched.

    Rules: same model and tool set or the cache misses (an aside delegate on a cheaper model always misses); fork promptly (5-min cache TTL); the child inherits the full context, so don't fork near the context limit; children that write files need their own worktree; prefer it when the parent holds >~30k tokens of useful context, use a fresh session for unrelated work. Measured 2026-10-03 on fir 1.25.0: forking a 97.7k-token session gave first-turn cacheRead 96,131 / cacheWrite 711 (~98% cached); on 1.24.4 (before the prompt split) cacheRead was 0.

  • Re-exec for local build testing — use /reexec to restart into the current binary while preserving the active session, or /reexec <path> to switch to a specific built binary.

  • In-place update — use /update to check for, download, and install the latest release, then automatically restart the session.

  • Compaction — when context grows large, fir automatically summarizes older messages to stay within the model's context window. Configurable via settings.json.

  • Review & Fix — use the review-and-fix skill to run a one-shot review pass over recent changes. Covers build breaks, security, correctness, test gaps, and simplification (code reuse, quality, efficiency). Fixes all issues found, verifies the build, and commits.

  • Aside (ephemeral side queries) — use /aside <question> to ask a quick side question using the current session context without adding the exchange to history. The response is shown as a notification and the main conversation is unaffected. Works even while a task is streaming. When given tool calls, /aside orchestrates multi-tool workflows ephemerally via ctx.call_tool() + ctx.side_query() — raw tool outputs never enter the main conversation, only the synthesis is returned. Use /aside read the 5 largest .go files and summarise to ask the agent to build and run an aside automatically. The aside tool can also be called directly by the agent — including in agentic delegate mode: pass goal (with delegate=true, mutually exclusive with tools) and the cheap delegate model drives its own read-only calls (whichever of read/glob/grep/ls this session has) in a loop until it can answer, bounded by max_iterations (default 8, clamped 1-20), a total tool-output cap and a wall clock; narrow the tool set with allow_tools. Agentic mode is delegate-only and never falls back to the executor model. Escalation (escalate=true) has a mode: self (advisor sees the full session, the default behaviour), strong (the model set as "strong_advisor" in aside.json — by default the strongest available advisor chain, Fable first — sees only a composed brief — original task, last few turns, the turns named in refs (ids or exact quotes), and the question — kept as one append-only, cache-friendly thread per session+model, compacted past ~40k tokens), or auto (default: strong when the user asks for it, when high_stakes=true, or when self was already consulted on the same problem; else self). Strong falls back to self with a note only when strong_advisor is "off" or no strong model is available. Every aside result (and /advise) ends with a token-accounting footer — [in 1.2k · read 48.3k · write 612 · out 900] — summed over every LLM call the escalation made; read vs write shows whether it hit the advisor prompt cache. For a subagent that needs this session's context to do work, fork yourself (see Sessions) rather than an aside delegate, which re-explores and misses the cache. Advisor replies that are only tool calls (native or written as <invoke> text) are rejected and retried, never returned; the default advisor chain ends with a non-adaptive model at effort off (add one yourself, e.g. anthropic/claude-sonnet-4-5:off, to an explicit aside.json chain); every failed advisor attempt is logged to the doctor log as advisor_failure with a kind.

  • Thinking levels — control reasoning depth: none, minimal, low, medium, high. Toggle with Shift+Tab or --thinking.

  • Tool steering — "steeringMode" in settings controls whether the agent runs tools one-at-a-time or in parallel.

  • call_tool bridge — extensions can call any registered tool (built-in, extension, or MCP) programmatically via ctx.call_tool(name, params). Results are returned directly and never enter conversation history. This enables extensions to build rich orchestration workflows.

  • MCP channel servers — MCP servers that advertise the claude/channel experimental capability can push messages into the running session via notifications/claude/channel notifications. Messages are injected into the agent conversation automatically. The server's channel_reply tool is a regular MCP tool. Configure channel servers in .fir/mcp.json like any other MCP server — no special config needed.

  • MCP configuration — configure MCP servers in .fir/mcp.json (project) or <agent-dir>/mcp.json (global, e.g. ~/.config/fir/mcp.json). You can also drop per-server files into the global ~/.config/fir/mcp.d/*.json directory — handy for managing one MCP per file. All sources are merged with precedence (low to high): (1) ~/.config/fir/mcp.json, (2) ~/.config/fir/mcp.d/*.json (lexically sorted), (3) <project>/.fir/mcp.json.

    Field reference — read it, do not recall it. The complete, authoritative definition of every mcpServers entry and every auth key lives in mcp-config-contract.md, next to this file in the skill directory. It is fir's own source, embedded verbatim, so it can never drift from the running binary. When a user asks what a field does, which fields exist, what a default is, or whether some key is still supported, read that file and answer from it. Do not answer such questions from memory of this skill, and do not reproduce the field list here. What follows is the operational half: worked examples, the login flow, and how to choose.

    Three transports are supported:

    • stdio (default) — launches a local subprocess:
      {
        "mcpServers": {
          "filesystem": {
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"],
            "env": { "NODE_PATH": "/usr/local/lib/node_modules" }
          }
        }
      }
      
    • streamable (recommended for remote) — connects to a remote HTTP MCP server:
      {
        "mcpServers": {
          "my-remote": {
            "transport": "streamable",
            "url": "https://my-server.example.com/mcp"
          }
        }
      }
      
    • sse — connects via Server-Sent Events (legacy HTTP transport):
      {
        "mcpServers": {
          "legacy": {
            "transport": "sse",
            "url": "https://old-server.example.com/sse"
          }
        }
      }
      
    • Any server may also declare roots — a list of file:// URIs that fir advertises to the server as its filesystem roots, for servers that scope their operations to them:
      {
        "mcpServers": {
          "filesystem": {
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-filesystem"],
            "roots": ["file:///home/user/projects", "file:///srv/data"]
          }
        }
      }
      
      When omitted (the default) fir advertises a single root: the process working directory. Entries are sent verbatim, so they must be file:// URIs, not bare paths.
  • MCP files extension (experimental) — per-server opt-in "files": {"enabled": true} turns on the draft io.modelcontextprotocol/files extension, used only with servers that advertise it on protocol 2026-07-28+. Tool arguments marked x-mcp-file are shown to the model as {"path": "<local file>"}; fir checks accept/maxSize, sends the file inline (within the server's maxInlineBytes) or uploads it to the server's upload_required target and retries the call, so neither bytes nor target URLs enter context. Download targets in a result's _meta are fetched (size/digest checked, 100 MiB default cap via files.maxDownloadBytes) into a private temp dir and the saved path is appended to the result. Sending a file needs approval: ACP asks the client with session/request_permission; TUI and headless sessions have no prompt and refuse unless files.autoApprove is set. Targets: https only, no redirects, MCP credential only on credentials:"include" same-origin/path targets, no private addresses; files.allowedOrigins restricts targets and is the only way to allow loopback/private or plain-http loopback targets. Design notes: docs/mcp.md (Files extension). Code: pkg/mcp/files*.go.

  • MCP authentication (OAuth) — remote (streamable/sse) MCP servers need no configuration to authenticate. fir connects unauthenticated; on a 401 it runs the MCP authorization-spec discovery chain automatically — RFC 9728 protected-resource metadata (from the WWW-Authenticate challenge, falling back to the well-known path), RFC 8414 authorization-server metadata, RFC 7591 dynamic client registration — then an authorization-code + PKCE login with the RFC 8707 resource indicator. Tokens are stored in auth.json under an mcp:<server> key, bound to the server URL they were minted for, refreshed proactively before expiry and silently on a 401.

    • fir never opens a browser on its own — the MCP manager holds no login-UI hook at all, so no reconnect cycle or background goroutine can start one. A server that needs a login reports run: /mcp login <server> (or: fir mcp login <server>). Log in with fir mcp login <server> from a terminal, or /mcp login <server> inside a session (TUI or ACP — over ACP a typed /mcp login only completes if the browser is on the agent host; ACP clients/relays use authenticate methodId mcp:<server> with _meta.auth.{interactive,sessionId}, a two-call URL-out/paste-back flow documented in docs/acp-interactive-auth.md); fir mcp logout <server> / /mcp logout <server> removes stored credentials; fir mcp list shows each server's auth state. After logging in out-of-band, /mcp reload [<server>] re-reads configs and credentials and reconnects servers that are down, so their tools load without a new session. Per-server health (ok | connecting | needs_auth | token_expired | error | disabled, config source, last error, tool count, fix) is in agent_introspect under mcp.servers and pushed to extensions as the mcp_state event. Set "disabled": true on a server to keep it configured but never connect.

    • Credentials never ride plaintext: a non-https:// server URL is rejected unless the host is loopback (127.0.0.1, ::1, localhost). Use "auth": {"mode": "none"} for a plain-HTTP server that needs no credentials.

    • Optional auth overrides (all absent by default — a spec-compliant server needs none):

      {
        "mcpServers": {
          "pat-server":  {"transport": "streamable", "url": "https://a.example/mcp",
                          "auth": {"token": "${MY_SERVER_PAT}"}},
          "no-dcr":      {"transport": "streamable", "url": "https://b.example/mcp",
                          "auth": {"client_id": "abc123", "scopes": ["mcp:read"]}},
          "no-auth":     {"transport": "streamable", "url": "https://c.example/mcp",
                          "auth": {"mode": "none"}},
          "bad-metadata":{"transport": "streamable", "url": "https://d.example/mcp",
                          "auth": {"authorization_servers": ["https://login.d.example"],
                                   "scopes": ["mcp:read"]}},
          "corp":        {"transport": "streamable", "url": "https://mcp.corp.internal/mcp",
                          "auth": {"allow_private_network": true}},
          "okta":        {"transport": "streamable", "url": "https://e.example/mcp",
                          "auth": {"client_id": "0oa1b2c3d4EXAMPLE",
                                   "redirect_uri": "http://localhost:8765/oauth/callback"}}
        }
      }
      

      Choosing a mode (the field reference has the exact semantics; this is when to reach for each):

      • Leave auth out entirely — the right answer for almost every server. fir discovers and logs in on its own.
      • Add token when the server issues personal access tokens and has no OAuth at all. Bearer mode is inferred; always use ${VAR} rather than pasting a secret into the file.
      • Add client_id (+ scopes) when login fails at the registration step — the authorization server does not do RFC 7591 dynamic registration.
      • Add authorization_servers when login fails at the discovery step — the server publishes no RFC 9728 metadata, or publishes wrong metadata.
      • Add redirect_uri when login reaches the authorization server but it rejects the callback — the issuer demands an exactly pre-registered redirect and ignores RFC 8252 §7.3 port variance (Okta is the canonical case). Pair it with client_id.
      • Add allow_private_network when discovery cannot even connect because the issuer is on an internal network behind a private DNS name.
      • Set mode: "none" only to opt out deliberately — e.g. a plain-HTTP local server that needs no credentials.

      Common failure modes:

      • run: /mcp login <server> — expected on first use. fir never opens a browser by itself; the user must run fir mcp login <server> or /mcp login <server>.
      • Credentials refused before any request — the server URL is not https:// and not loopback. fir will not send a token in cleartext; fix the URL, or use mode: "none" if there is genuinely nothing to protect.
      • A pinned redirect_uri that "looks identical" but fails — it is compared literally, so localhost and 127.0.0.1 are different values. Match what is registered character for character, and make sure the pinned port is free at login time.
      • Browser cannot reach the box's loopback (phone sign-in through a gateway)? Pin an https redirect_uri plus callback_listen: "127.0.0.1:<port>", or set FIR_MCP_OAUTH_REDIRECT_BASE=<https base> so fir uses <base>/<P>/callback while listening on 127.0.0.1:<P>. The proxy must forward the path unchanged. The variable is ignored when client_id or redirect_uri is set.
      • allow_private_network set but still blocked — a private IP literal is rejected upstream with no opt-out. Use a DNS name with a real certificate.
      • OAuth-only keys in bearer/none mode are rejected loudly rather than ignored, because the OAuth chain never runs there; remove them or switch mode.
      • Stale credentials after changing authorization_servers are invalidated automatically; tokens live in auth.json under mcp:<server>, and fir mcp logout <server> clears them.
  • MCP inspection — use /mcp to see all configured MCP servers with their connection status, transport, capabilities (resources, prompts), and a full list of exposed tools with descriptions. Use /mcp <name> for detailed info about a specific server. Use /mcp reload to re-read mcp.json and mcp.d/*.json configs from disk and apply the diff to running servers without a full session reload; it reports collisions when multiple files define the same server name.

settings.json Reference

All fields are optional. Nested objects merge recursively; arrays and primitives from overrides win.

{
  // Model defaults
  "defaultProvider": "anthropic",        // Provider name
  "defaultModel": "claude-sonnet-4-20250514",  // Model ID
                                         // A pin here beats the provider's own default forever.
                                         // /doctor warns when the provider has moved on to a
                                         // newer generation of the same model line (never rewrites it).
  "defaultThinkingLevel": "medium",      // none, minimal, low, medium, high

  // UI
  "theme": "dark",                       // Theme name or path
  "hideThinkingBlock": false,            // Hide thinking blocks in output
  "collapseChangelog": false,            // Collapse changelog on startup
  "turnCache1h": true,                   // Anthropic: cache each turn's user message for 1h (TUI/ACP; env FIR_TURN_CACHE_1H=0/1 overrides)
  "doubleEscapeAction": "tree",          // "tree" or "quit"
  "autocompleteMaxVisible": 5,           // Max autocomplete suggestions shown
  "showHardwareCursor": false,           // Show hardware cursor

  // Shell
  "shellPath": "/bin/bash",              // Shell for bash tool
  "shellCommandPrefix": "",              // Prepended to every bash command

  // Compaction
  "compaction": {
    "enabled": true,                     // Auto-compact when context is large
    "reserveTokens": 16384,              // Tokens reserved for compaction summary
    "keepRecentTokens": 20000,           // Recent tokens to keep uncompacted
    "maxContextTokens": 0               // Hard token cap — compact when exceeded (0 = disabled)
  },

  // Branch summary
  "branchSummary": {
    "reserveTokens": 16384               // Tokens reserved for branch summaries
  },

  // Retry on transient errors
  "retry": {
    "enabled": true,
    "maxRetries": 3,
    "baseDelayMs": 2000,
    "maxDelayMs": 60000
  },

  // Thinking token budgets per level
  "thinkingBudgets": {
    "minimal": 1024,
    "low": 4096,
    "medium": 10240,
    "high": 32768
  },

  // Terminal display
  "terminal": {
    "showImages": true,                  // Render images in terminal
    "clearOnShrink": false               // Clear screen when terminal shrinks
  },

  // Image handling
  "images": {
    "autoResize": true,                  // Auto-resize large images
    "blockImages": false                 // Block all image content
  },

  // Markdown rendering
  "markdown": {
    "codeBlockIndent": "  "              // Indentation for code blocks
  },

  // Model filtering (glob patterns; empty = all)
  "enabledModels": ["claude-*", "gpt-4*"],

  // Extension/skill/theme allowlists (empty = all)
  "extensions": [],

  // Extension search directories (relative paths resolve against cwd)
  //   "extensionPaths": ["extensions"]  → looks for ./extensions/ in every project
  "extensionPaths": [],

  // Additional resource lookup paths (resolved per-invocation relative to cwd)
  // Use absolute paths, ~/... paths, or relative paths.
  // Relative paths resolve against the working directory, making them portable:
  //   "skills": ["skills"]  → looks for ./skills/ in every project you open
  //   "skills": ["~/shared-skills"]  → always points to the same directory
  "skills": [],
  "themes": [],
  "enableSkillCommands": true,           // Allow /skills slash commands
  "enableSysExtensions": true,           // Allow extensions to prepend [SYS_EXT] context blocks

  // Agent behavior
  "steeringMode": "one-at-a-time",      // "one-at-a-time" or "auto"
  "followUpMode": "one-at-a-time",      // "one-at-a-time" or "auto"
  "transport": "sse",                    // Transport protocol
  "serverTools": ["web_search"],         // Anthropic server-side tools: "web_search", "web_fetch", "code_execution"
                                          // You can also use raw types (e.g. "web_search_20260209"); model support is controlled per-model in models.json
  "serverCompaction": {                  // Anthropic server-side context compaction
    "enabled": true,
    "triggerTokens": 150000,             // When to trigger (min 50000, default 150000)
    "instructions": ""                   // Custom summarization prompt (replaces default)
  },

  "debugLog": {                          // Debug log (debug.log) rotation
    "maxSizeMB": 100,                    // Rotate when the file reaches this size
    "keep": 20,                          // Number of compressed backups to retain
    "compress": true,                    // gzip backups (debug.log.N.gz)
    "checkEveryWrites": 1000,            // Stat the file at most once per N writes
    "checkEverySeconds": 30              // ...or once per this many seconds
  },

  "sessionStores": [                     // Extra READ-ONLY session stores (global settings only;
    { "path": "~/fir-traces/zbox",       //   see "Session stores"). env FIR_SESSION_STORES replaces it.
      "label": "zbox" }                  //   label defaults to the dir name; "local" is reserved
  ],

  "auth": {                              // Credential storage (global settings only; see "Secret storage")
    "storage": "file"                    // file (default) | auto (OS secret store if it answers a probe,
                                         // else auth.json) | keyring (require it). env FIR_AUTH_STORAGE overrides.
  },

  "mcp": {                               // MCP (Model Context Protocol) behaviour
    "toolTimeoutSeconds": 120            // Default per-call timeout for model-dispatched MCP tools
                                         // (default 120; <=0 disables; env FIR_MCP_TOOL_TIMEOUT overrides).
                                         // Extension/pipe/wait calls (incl. timeout=-1) are never clipped.
  },

  // Internal (managed by fir)
  "lastChangelogVersion": "",            // Last changelog version shown to user
  "packages": []                         // Package metadata
}

04 同仓库更多 Skills