Skip to content

The CLI

For scripting af — from a shell, a CI job, or another agent. After this page you will know how a command picks its project, which groups emit JSON, and what each verb does.

Everything the TUI does is also scriptable. The af sessions and af tasks command groups output JSON to stdout and errors to stderr, so they compose with jq and shell scripts. Pass --json to wrap output in a {data, error} envelope for structured error handling. The TUI and the CLI share the same state — you can mix them freely.

Run af <command> --help for the authoritative flag list of any command.

Project scoping

Like the TUI and the web UI, every af sessions and af tasks command is scoped to one project. The rules are the same for all of them:

  1. --repo <path> names the project explicitly, and always wins.
  2. Otherwise the current directory's git repository is the project. A linked worktree resolves to its main repository, so a command run from inside a session's worktree still acts on the real project.
  3. Outside a git repository there is no project context:
    • sessions create and tasks add require --repo — a new binding is never guessed.
    • Listing spans every project.
    • A <title> or task <id> resolves across projects, but a title held by several projects is ambiguous and errors rather than picking one.

Acting on another project always takes an explicit --repo. The read-only list commands also accept --all as an explicit opt-in to span every project.

af sessions list              # this project's sessions
af sessions list --all        # every project's sessions
af sessions list --repo /repos/beta
af sessions list --live --max-age 24h --limit 50
af tasks list                 # this project's tasks
af tasks list --all           # every project's tasks
af tasks list --repo /repos/beta

These defaults changed (#1893)

af tasks list used to list every project's tasks; from inside a repository it now lists only that project's. Pass --all for the old behavior.

af tasks get, update, remove, and trigger used to accept --repo and silently ignore it, so an id reached any project's task from anywhere. They now refuse an id owned by a different project and name the --repo that would reach it.

Remote daemons

For the session reads that follow --daemon-url/AF_DAEMON_URL (sessions list, get, watch, preview, and attach), rule 2 does not apply: your current directory names a repository on this machine, which says nothing about the daemon's projects, so only an explicit --repo scopes a remote lookup.

Every af tasks verb follows the target too (list, add, get, show, update, remove, trigger, restart). A remote target reaches that daemon's task routes and never falls back to this machine: a daemon that cannot be reached is an error rather than a local answer, a remote-targeted verb never starts a local daemon, and a daemon too old to serve one of the routes is refused by name and version rather than written around.

Two things work differently against a remote daemon, both because a path means something different on each host:

  • --repo no longer scopeslist, get, show, update, remove, trigger and restart refuse it. A project's identity is derived by hashing its path on this machine, so filtering the daemon's tasks by it would silently match nothing whenever the two hosts hold the project at different paths: an empty list for a project that has tasks. Drop it — task ids are unique, so the id resolves across the daemon's projects, exactly as it does from outside a git repository locally.
  • --repo still binds, and tasks add requires it. There it names a path on the daemon's host, sent as typed and resolved there: ~ is not expanded and no local git probe runs. tasks update --project-path works the same way. The success line names the daemon URL beside the path, unabbreviated — ~/… would read as your own file whenever the two hosts share a home layout. An omitted --program is left to the daemon's own default_program rather than resolved from your config.
$ af tasks add --daemon-url http://box:8443 --repo /srv/projects/alpha \
    --name nightly --prompt "sweep" --cron "0 3 * * *"
{
  "daemon_url": "http://box:8443",
  "id": "a1b2c3d4",
  "project_path": "/srv/projects/alpha"
}

af — the TUI

cd your-project    # a git repo
af                 # launch the TUI
Flag Description
-p, --program Agent to run in new sessions, one of claude, codex, aider, gemini, amp, opencode, devin. To pass custom paths or flags, use program_overrides in the config instead — see configuration.md.

af sessions

All subcommands honor --repo <path> to target a project other than the current directory's (see Project scoping), and --json to wrap output in the shared envelope.

Session titles are unique within a project, not across projects — the same name may exist in several repos at once. Every command that takes a <title> resolves it inside the repo named by --repo, falling back to the current directory's repo. With no repo context (no --repo, cwd outside a repo) a title held by exactly one session anywhere still resolves; a title held by sessions in several projects is ambiguous and reports an error naming them rather than picking one:

session "foo" exists in multiple projects: /repos/alpha, /repos/beta — pass --repo to pick one

Against a remote daemon (--daemon-url/AF_DAEMON_URL), the split follows the transport:

  • Served by the targeted daemonlist, get, watch, preview, attach — ignore the current directory rather than sending it as a scope, since it names a repo on your machine, not the daemon's. A bare title resolves across the remote's projects.
  • Everything elsekill, archive, restore, retry-limit, send-prompt, tab create/delete — reaches the local daemon regardless of --daemon-url, so it stays scoped to the current directory.

Caveat for the reads: --repo becomes an id by hashing the path as given on this machine, so it only disambiguates when the daemon has that project checked out at the same absolute path. Prefer a bare title against a remote and let the ambiguity error tell you when to narrow it.

Session titles for local, relocatable worktrees must also map to distinct archive directories within the project, across live and archived sessions with af-owned worktrees. In-place (--here) sessions and legacy external worktrees do not claim archive directories because af cannot relocate them. Archive directory names must be unique under Unicode case folding and Unicode normalization (NFC) on every platform: Feature / feature and composed / decomposed Café collide. This deliberately stricter comparison keeps a repo’s naming rules consistent on Linux and macOS; the on-disk spelling stays unchanged. Off-box sessions (Docker, SSH, and remote hooks) do not claim local archive directories. For example, feature/login and feature-login both map to feature-login, so creating the second is refused with the colliding title. If an archive destination is already occupied, archive refuses before stopping tabs and names the directory and its recorded owner; the session stays live.

One exception to per-project titles: remote hook sessions share a global name namespace, because the slug reaches launch_cmd/delete_cmd verbatim and external provisioners key real sandboxes on it — see remote-hooks.md.

af sessions list [--all] [--live] [--status <state>]      # list and filter daemon-side
af sessions get <title>                                   # fetch one session
af sessions create <title> [--prompt "..."] [--program <agent>] [--here]
# --name <title> remains an alias for existing scripts
af sessions send-prompt <title> "..."                     # append a prompt; report observed delivery status
af sessions send-prompt <title> "..." --create            # send-or-create; report observed delivery status
af sessions tab-create <title> --kind shell               # spawn the same bare $SHELL Terminal tab the TUI/web opens
af sessions tab-create <title> --command "<cmd>"          # spawn a process tab in the session's worktree
af sessions tab-create <title> --kind web --port 5173     # web/iframe tab pointing at a localhost dev server
af sessions tab-create <title> --kind vscode              # VS Code editor tab on the session's worktree
af sessions tab-delete <title> --name <tab>               # delete a single tab (the daemon won't respawn it)
af sessions tabs create <title> --command "<cmd>"         # alias for tab-create (hyphen verb still works)
af sessions tabs create <title> --kind web --url <url>    # alias for tab-create (hyphen verb still works)
af sessions tabs delete <title> --name <tab>              # alias for tab-delete
af sessions preview <title>                               # snapshot the session's pane
af sessions attach <title>                                # attach interactively (foreground)
af sessions whoami                                        # report the session this shell is inside
af sessions archive <title>                               # default done action: restorable later
af sessions archive --self                                # archive the current session (resolved via whoami)
af sessions handoff <title> [--to <agent>] [--account <name>] [--brief <mission>]
af sessions retry-limit <title>                           # retry a limit resume or inspected, unconfirmed handoff
af sessions kill <title>                                  # permanently destroy + prune owned branch
af sessions restore <title>                               # restore an archived/lost/dead session

Flags:

  • list: --all spans every project's sessions explicitly; it is mutually exclusive with --repo. --live excludes archived rows, including task-spawned archives. Repeat --status to select one or more lifecycle states (running, ready, lost, dead, archived, or limit-reached); repeated values are ORed, while --live, --max-age, and --limit compose as additional constraints. --max-age <duration> keeps sessions created within that age (for example 24h), and --limit N returns at most N rows after filtering in the existing stable order. Every filter is applied by the daemon before transfer; with no filter flags, output is unchanged. If no daemon is reachable, the same semantics are applied to the local disk fallback. Each listed row also names its own enums — status_name, liveness_name, and tabs[].kind_name beside the unchanged integers — so the value to pass back to --status is in the output rather than only in this page (liveness_name is the one --status matches, and it round-trips exactly; status_name names the older single-axis integer and is not a filter value — see the JSON API's session state names).
  • create: takes <title> positionally; --name <title> remains an alias for existing scripts. --prompt sends an initial prompt, and --program selects the agent enum (defaulting to the configured default_program). --here (alias --in-place) attaches the session to the repo's existing working tree at its current branch instead of cutting a new worktree+branch: the agent runs in the repo root, no branch is created, and killing the session never removes the working tree or branch. Requires a git repository (the current directory, or --repo); incompatible with remote sessions. The title root (any casing) is reserved for the daemon-managed root agent — see the root_agents key in configuration.md.
  • send-prompt: adds status to its existing {"ok":true} acknowledgement: delivered, not-delivered, sent-unverified, or could-not-confirm, from the daemon's bounded delivery observation. sent-unverified means tmux accepted the paste and Enter while a readable pane did not render exact content proof; could-not-confirm means the observer itself was unavailable. Neither is promoted to delivered. The command does not add a second confirmation wait on a confirmed or ambiguous delivery; an observed-absent (not-delivered) outcome is redelivered once after a short wait before the final status is reported. --create auto-creates the session if it doesn't exist; --program picks the agent when creating (a newly started session is could-not-confirm because that path has no pane observation).
  • tab-create: creates a new tab in a session. --kind shell starts the user's bare $SHELL through the same daemon operation the TUI and web use. Its canonical kind/base name is shell; Terminal is the presentation-only UI label, and --name/--command are rejected for this kind rather than ignored. Shell, process (the default), and VS Code tabs all reject --url/--port rather than ignoring them. By default, --command is run in the worktree as a process tab; --name sets the tab's name — the handle the other tab verbs address it by, not the label the TUI renders (defaults to the command's basename; sanitized to [A-Za-z0-9_-], then auto-suffixed -2, -3, … on collision, so the name you pass is not always the name you get). With --kind web, creates an iframe tab targeting --url (or --port as a localhost: convenience) instead. With --kind vscode, creates a VS Code editor tab on the session's own worktree — it takes no target, so --url/--port/--command are rejected, and it needs code-server (or openvscode-server) installed, which af detects rather than bundles. Both are browser panes with no PTY — see web.md. The resolved tab name is printed as {"name": "..."} so scripts/agents can address it. The tab persists and reconnects across a daemon/af restart like every other tab — except that a process tab's command runs once at creation and af never runs it again. A command that exits non-zero immediately (a mistyped name, exit 127) fails tab-create with its status and last output, and no tab is added. Across a restart af reattaches to the process tab's pane: a running command keeps running, and a finished one keeps its output and records exit: {status, status_known, at} on the tab row in af sessions get. A process tab whose tmux session is gone entirely restores inert. In an account-scoped session, af stops a running process tab it did not start under the session's account (once, at restart), and an account swap stops every running process tab; neither re-runs the command, and the row's exit.stopped_by (account-scope or account-swap) says why. A finished process tab with nothing left running is kept through both, and archive and kill tear it down like any other tab. An off-box session (docker/ssh/sandbox/hook) admits only an external HTTPS web tab: that row is metadata-only, needs no PTY, and spawns no process. Shell/process/VS Code tabs still require local resources; loopback web targets still need an off-box relay, and plain HTTP cannot be framed by the HTTPS web UI.
  • tab-delete: the counterpart of tab-create--name (required) selects the tab to delete. Pass the tab's name (as reported by af sessions get), not the label the TUI tab bar shows: agent and shell tabs render a fixed Agent/Terminal that is not their name (agent/shell). A miss lists the tabs that exist with both spellings, so a wrong name is a next step rather than a dead end. The tab is removed from the daemon's session state and any backing tmux window is killed; the removal is persistent (the daemon won't respawn it, and it doesn't return on restart). The deleted tab's name is printed as {"name": "..."}. The agent tab can't be deleted — use af sessions kill to tear down the whole session. An archived session's tabs can't be deleted either (its preserved web-tab URLs must still be there for restore) — restore it first. Targeting a missing tab or session is an error. On remote sessions, admitted metadata-only web tabs can be deleted, renamed, and reordered; PTY-backed kinds are never admitted there.
  • tabs {create,delete}: additive noun-subcommand aliases — af sessions tabs create == af sessions tab-create and af sessions tabs delete == af sessions tab-delete (same flags and output). The hyphen verbs are kept for existing scripts; nothing is renamed. There is no tabs list — list a session's tabs via af sessions get.
  • archive: the default way to finish with a session. It tears down the session's tmux and moves its git worktree out to the global archive directory (<AGENT_FACTORY_HOME>/archived/<repoID>/<title>/), preserving the branch and any uncommitted changes. The session is not deleted — it becomes a quiescent archived row that survives restarts and is never auto-restored. Shell/process tabs do not survive the archive (their processes are torn down), but web tabs do — their target URLs are preserved and render again on restore. While archived they are inert: the tab shows a placeholder rather than loading, and it can't be deleted. Prints {"ok": true, "title": "...", "archived_path": "..."}. An off-box session (docker/ssh/hook) archives differently — it pushes its branch to origin and tears the sandbox down (durability lives in GitHub, not the sandbox), and restore re-provisions from the pushed branch. Not available for in-place (--here) sessions (they don't own a relocatable worktree). Bring it back with restore. Pass --self instead of a <title> to archive the current session — it resolves the caller's own session the way whoami does, so an agent can archive itself once its work is done; --self and a <title> are mutually exclusive, and it errors with an actionable message when not run from inside a session.
  • handoff: continues a session under another agent, another account, or both, in the same worktree and branch. Supply --to <agent>, --account <name>, or both. With --account alone, the current agent stays selected; the account must differ from its current account, except to retry a committed account swap (naming the swap's recorded target). With --to alone, the target must be a different supported agent. The selected account must be registered for the agent the target's resolved command runs and not currently walled in the limit ledger. An account handoff moves the session's pin to that explicit account; it does not enable automatic rotation. A scoped session changing agents needs --account to name the incoming agent's account whenever the target's resolved command can carry one — account names belong to one agent — while a target whose resolved command has no account support drops the scope and reports the drop on from_account. Admission classifies the resolved command rather than the enum, so program_overrides moves a target between the cases: aider redirected to codex requires a codex account, and codex redirected to aider drops the scope — while a resolved command af cannot classify as an agent at all refuses rather than drop the pin on an unproven answer. The drop is one-way: a later handoff back to an account-capable agent does not restore it, so name the account again with --account. A scope-dropping handoff restarts only the agent pane — shell, process, and VS Code sibling tabs keep running under the dropped account's environment, so the handoff is refused until those tabs are closed. An ambient session can use --to alone to remain ambient, or combine --to and --account to select a registered target-agent account. The session keeps its identity, task binding, and uncommitted work. Agent conversations are not portable between providers, so a different-agent handoff delivers a mission brief describing the goal and outgoing work (git log/git diff on the branch). A same-agent account handoff continues the task on the selected account; for claude and codex it keeps the conversation by copying it into the new account's home and resuming it, and if that copy cannot be made the new account starts fresh with a brief that says why. Use --brief to replace a stale stored goal. The recorded branch tip is frozen after the outgoing runtime stops and before the replacement starts, marking the attribution boundary between their work. Prints {"ok": true, "title": "...", "from": "claude", "to": "claude", "from_account": "work", "to_account": "personal", "head_sha": "..."}; account fields are omitted for ambient identities. Local-worktree sessions only. See usage-limits.md.
  • retry-limit: resumes a session parked at a provider usage-limit wall or explicitly retries an agent or account handoff whose mission delivery could not be confirmed. It calls the same daemon recovery action as the TUI's c key and the web's Retry button: an exited agent is re-spawned when necessary and the pending prompt is re-delivered (or continue when a limit-blocked interactive session has no stored prompt). For an unconfirmed handoff, inspect the pane first: the initial submission may already have landed, automatic redelivery is suppressed, and this command is the operator's override. Prints {"ok": true, "title": "..."} and refuses a session with neither recovery obligation. If delivery succeeds but its final disk settlement is still pending, the success also carries "warning": "..."; the mission landed, so do not retry it. See usage-limits.md.
  • kill: permanently deletes a session: stops its terminals, deletes the stored row, and removes only the worktrees and branches af owns — user-owned resources stay. Deletion is permanent; uncommitted or unmerged work in af-owned resources may be lost. Use af sessions archive <title> to keep it restorable instead. --force is accepted but has no effect (kept for backward compatibility).
  • restore: restores an archived, Lost, or Dead session. Archived sessions move their worktree back next to the repo, re-register it, re-spawn the agent (shell/process tabs are not restored — their processes were torn down at archive time; web tabs come back with their target URLs, since a web tab is just a URL and has no process), and mark the session running. Lost/Dead sessions recover in place, rebuilding a missing worktree when possible and resuming the recorded agent conversation when required. Prints {"ok": true, "title": "...", "worktree_path": "..."}. Fails if the session is not restorable, or if its origin repo is gone (an archived worktree is left intact for manual recovery). Honors --repo like kill.

af tasks

Tasks deliver a prompt to an agent automatically — on a cron schedule or whenever a long-running watch script emits a stdout line. Full semantics (trigger × delivery matrix, watch-script contract, status model) live in tasks.md. All subcommands honor --repo <path> and --json, follow the shared project scoping rules, and honor --daemon-url/AF_DAEMON_URL — see remote daemons.

af tasks list [--all]
af tasks add --name <n> --prompt <p> --cron "0 9 * * *" [--target-session <title>] [--program <agent>]
af tasks add --name <n> --watch-cmd <cmd> [--prompt "... {{line}} ..."] [--target-session <title>]
af tasks get <id>
af tasks show <id>             # human-readable: schedule health and audit trail
af tasks update <id> [--cron ...|--watch-cmd ...] [--prompt ...] [--target-session ...] [--project-path <repo>] [--program <agent>] [--enabled true|false]
af tasks restart <id>          # reload an edited watch script (watch tasks only)
af tasks trigger <id>          # run a cron task immediately (cron tasks only)
af tasks remove <id>

af tasks show answers "is this thing actually running?": the trigger, whether the daemon has it armed, when the live scheduler entry fires next, whether it has missed scheduled runs and how many, and the bounded audit trail of who created, updated, enabled, or disabled it. The same facts ride af tasks list/get as the overdue, missed_occurrences, next_run_at, arming and audit fields, and af doctor raises a WARN row for any task that has stopped firing. See tasks.md.

Exactly one of --cron / --watch-cmd per task. On update, setting one trigger clears the other. --target-session "" explicitly reverts to create-a-session-per-run; omitting the flag leaves it untouched. --project-path moves the task to that repository; --repo still names the task's current project for authorization. --program accepts the same agent enum as tasks add; omitting it keeps the task's current program.

Project binding

A task is bound to exactly one project when it is created, and every run's worktree is created inside it. The binding comes from --repo, or from the current directory's project. The two flags on update do different jobs: --repo scopes which task may be edited and never re-binds one, while --project-path <repo> moves the task to another existing git repository — that path becomes both its new working directory and its project binding. So af tasks update <id> --repo /repos/alpha --project-path /repos/beta authorizes the task in alpha and moves it to beta.

The project a task belongs to is recorded as an id resolved when the task is bound, not re-derived from its path on each read. This is why deleting a directory a task points at — a subdirectory or a linked worktree — never hides the task from its own project.

If another client re-binds a task to a different project while a command is acting on it, the command is refused rather than applied to the moved task:

$ af tasks remove a1b2c3d4
{"error":"task \"a1b2c3d4\" was re-bound to a different project while this command was running
 (expected /repos/alpha, now /repos/beta) — nothing was changed; re-run the command to act on it
 in its current project"}

Nothing is changed when this happens, and re-running acts on the task where it now lives.

tasks add reports the resolved binding, so you can check it is the project you meant:

$ af tasks add --name nightly --prompt "sweep" --cron "0 3 * * *"
{
  "id": "a1b2c3d4",
  "project_path": "/repos/alpha"
}

Because the binding is inherited from the current directory, running tasks add inside a throwaway clone of a repository binds the automation to the clone rather than to the real project — the task's sessions then appear under a project nobody looks at. Creating a task from a clone that lives inside AGENT_FACTORY_HOME is refused for that reason; pass --repo to name the intended project. Session worktrees under AGENT_FACTORY_HOME are unaffected: a linked worktree resolves to its main repository, so an agent adding a task from inside its own session binds to the real project.

af daemon

The background daemon hosts task cron schedules, watch-task scripts, session monitoring, and the web UI. It starts on demand whenever af runs and an enabled task exists; installing it as a user-level autostart unit (systemd user service on Linux, launchd agent on macOS) keeps scheduled tasks firing after reboots. See tasks.md.

af daemon install      # register autostart at login
af daemon restart      # restart a running daemon and re-adopt live sessions
af daemon adopt        # hand a detached daemon back to the installed autostart unit
af daemon uninstall    # remove the autostart unit (the daemon still starts on demand)
af daemon status       # read-only health, supervision, and config freshness (+ --json)

af daemon status uses the same no-spawn Ping as af doctor, plus bounded read-only service-manager inspection. It reports the responder PID separately from the recorded PID, whether the installed unit owns that responder, and whether the listener/auth config on disk matches what the daemon booted with. Manager failures and older daemons render as unknown; they are never guessed into “unsupervised” or “current.” It never starts, stops, or reloads a daemon or unit.

af daemon restart is safe to run after replacing the af binary. It asks a running daemon to shut down cleanly, restarts the autostart unit when one is installed, otherwise starts an ad-hoc daemon from the current binary. If no daemon is running, it exits successfully without starting one.

af daemon adopt reclaims supervision when a daemon is running detached from its unit — an ad-hoc child a live af spawned, which the service manager can no longer restart. It stops that daemon, starts the installed unit in its place, and verifies the unit now owns the process answering the control socket. Live sessions keep running; the new supervised daemon re-adopts persisted state on startup, exactly as af daemon restart does. It needs an installed unit that serves this home; if that unit already owns the running daemon, adopt reports so and changes nothing.

af config

Read the current repository's effective config (or global config outside Git), inspect another repository, and write the global config (~/.agent-factory/config.toml) from the CLI. Config is a hand-editable file read by the daemon and TUI at startup (not daemon-owned state). --json wraps output in the shared envelope (success and error).

af config list                                      # current repository's effective values
af config get <key>                                 # one current-repository value
af config get <key> --explain                       # candidates and why one won
af config list --repo ../another-project            # inspect another repository
af config get program_overrides.codex --repo . --explain
af config set <key> <value>                         # global write, preserving comments/ordering
af config migrate                                   # rewrite deprecated keys to their current spelling

Bare get/list report the current repository's effective values (defaults plus global, legacy per-repo, checked-in, and personal layers); outside Git they report global values. --repo <repository-path> inspects another repository, while the deprecated --project read alias remains accepted for compatibility. The path is a selector only and does not register or persist a project. --explain reports the on-disk effective value, merge policy, precedence, every candidate's path/presence/result/reason, and per-leaf origins for merged maps/tables. Displayed source locations keep the selected/configured path spelling; symlinks are resolved for identity comparison, never for display. The output also says explicitly that the running daemon value was not checked. JSON mode carries the same data structurally.

--daemon-url/AF_DAEMON_URL means one thing across the whole group, and it is never silently dropped (#3661, #3679). They are persistent root flags, so they appear in every subcommand's --help, and accepting one and ignoring it would hand you a confident verdict about the wrong host — or, on a write, a success line naming a local path for a change you believe you made remotely. Two answers, by what the verb can actually reach:

  • Routed. Global af config set and af config unset are sent to the targeted daemon's admission-gated write — the same handler the web config form posts to (#3231) — and the success line names the daemon's own path and URL, so which machine changed is never in doubt. A daemon too old to serve that route is refused by name and version; af never falls back to writing this machine's config for a remote target, because that is precisely the wrong-machine mutation the flag exists to prevent. Everything is unchanged with no target set: the local control socket, and today's local write when no daemon is running.
  • Refused. get, list, validate, migrate, and the --project form of set/unset read or write a file on the machine they run on that no remote daemon owns — a personal per-project override, in the --project case. They refuse a remote target and tell you to run them on the daemon host.

set edits only the target value's bytes and preserves unrelated comments, blank lines, and key ordering. It validates with the loader's own rules before writing, so it cannot produce a config that fails to load. Every global config key is settable: scalars use their ordinary text form, tables and session_env_passthrough use compact JSON, and network.cors_allowed_origins remains comma-separated. program_overrides and limit_patterns accept either their whole JSON object or a dotted single-entry key. The canonical network keys live under network.*; the old flat spellings listen_addr, preview_listen_addr, require_token, require_loopback_token, and cors_allowed_origins remain permanent TOML, JSON, and CLI aliases. Without --project, set writes the global config; with --project <id-or-path> it writes a permitted personal per-project override instead. af config unset clears a per-project override (with --project) or a migrated global backend setting (without it). A global write uses the same apply-on-save path as both config panes (#2480); most keys take effect at once, and set prints the exact notice for keys that wait for the next daemon or af launch. A raw hand-edit is not applied for you and takes effect on the next af/daemon start.

For TUI appearance, use af config set appearance light, dark, or system (default); af config get appearance reads the choice. It takes effect at the next TUI launch on that machine. The CLI has no persistent renderer, and the browser's header preference is independent. System uses terminal background with a dark fallback in the TUI and OS appearance in the browser.

The CLI rejects theme and all theme.* CLI keys explicitly, directing users to appearance. On read, legacy theme Light/Dark values migrate to matching appearance; auto/system/Nord/Zenburn/custom tables migrate to System, with any existing valid appearance taking precedence. The migration logs the old key and replacement value and persists once, idempotently. No separate af config migrate invocation is needed for this retirement. Read-only diagnostics (LoadConfigReadOnly and af config validate) preserve their no-write contract and do not persist migration changes. See Appearance migration. Launching the TUI reads appearance locally and must never activate pending listener/auth edits; explicit apply-on-save remains available.

migrate is the command the deprecated-key warnings name. It rewrites the deprecated spellings in the global config to their current ones in place, prints the diff, and keeps the previous file beside it as config.toml.bak (an existing backup is never overwritten — the copy is numbered instead). It changes spelling, never meaning: a value written on one line is carried over as its own bytes, quoting and all, while a value spread over several lines (an array, typically) is re-encoded compactly rather than relocated as raw text, so its formatting can change even though its contents do not. Either way the rewritten file is re-parsed before anything is saved, and a rewrite that would change even one effective value is refused rather than written. A leading byte-order mark is preserved. Running it twice is safe — the second run reports nothing to migrate. The readers of the old spellings stay, so an older config keeps loading and your running configuration is untouched. Worth knowing before a downgrade: the grouped spellings have only been read since #3354 (2026-08-14), so an af older than that falls back to the built-in default for a migrated key rather than reading it. For most keys that default is the conservative one (strict host-key checking, no credential mount). Two cases are not, both because the listener defaults to a live 127.0.0.1:8443. Migrating network.require_token = true loses the token an older binary could read (network.require_loopback_token only matters alongside it, being inert on its own) — exactly what that key exists to prevent on a shared host. And migrating an empty network.listen_addr hides the fact that the web server was turned off, so an older binary starts one where the operator had none. migrate compares what such a binary saw before and after the rewrite, and prints a caution naming the backup when a migration costs you either. A legacy config.json is converted to config.toml on the way in, exactly as any af start would convert it.

Two things it will not do. A key written in both spellings with different values is refused, naming the key: af has a documented winner at load time (the grouped value), but no migration should make that tie-break permanent on your behalf — delete whichever line is wrong and run it again. The same key in both spellings with the same value is not ambiguous, so the redundant flat line is simply dropped. And root_agents is reported and left exactly where it is: its successor is a registered project's personal [root_agent], so migrating it would mean registering projects for you — durable state outside the config file. The keys that can move still move, and the report names each legacy path with the step that ends it: register the path as a project, set enabled = true plus the optional program in its personal [root_agent], then remove its root_agents entry — stopping before the removal leaves the key, and therefore the warning, exactly where they were.

Maintenance commands

af version             # print the version and the release URL
af debug               # print the resolved config and its path
af keys                # print the effective TUI key bindings (defaults + [keys] rebinds)
af upgrade             # self-upgrade to the latest GitHub release (Linux/macOS)
af doctor --setup      # verify first-run prerequisites and writable storage
af doctor              # diagnose setup, leaked resources, and daemon health
af doctor --fix        # also apply the safe remediations
af bug-report          # bundle logs + versions + tasks + redacted state into one file to attach
af bug-report --json   # emit the structured manifest to stdout instead of writing a file
af reset               # nuclear option — see below

af bug-report collects one shareable diagnostics file: the daemon log tail (bounded to the last ~2MiB / 5000 lines), versions (af, Go, OS/arch, the daemon snapshot), the configured tasks, the session state from instances.json, the af daemon status health snapshot, and the global config. It writes a single text file (default ~/af-bug-report-<ts>.txt, mode 0600; override with -o/--output) so you can read the whole thing in one scroll before attaching it. Redaction is best-effort: free-text and secret-bearing fields (session titles, session prompts, task prompts, tab and session commands, tab names, account labels, remote metadata) are dropped — a registered account label is replaced by the same marker wherever it appears, in the session records and in the daemon log and config sections that name it as text; every directory the bundle names is replaced by the role it plays ([repo:N], [worktree:N], [af-home], ~) rather than by its own name, and your username by [user]; and known credential shapes are scrubbed everywhere — but perfect redaction is impossible, so review the file before sharing it publicly. It is read-only and local (like af doctor): it never dials the daemon or the network, and is not part of the HTTP af api surface.

By default it also opens a pre-filled GitHub issue draft in your browser, always against the agent-factory project — never whatever repo you happen to be in, since the report is about af itself. The draft body carries a bounded, redacted excerpt of the key diagnostics (versions, daemon status, counts, and the newest log lines) so the report is useful as filed; the complete bundle is far too large for a URL, so it stays on disk for you to attach. Nothing is submitted for you — review the draft, drag the bundle onto it, and click Submit yourself. Use -o/--output or --file to skip GitHub and only write the file.

af doctor --setup is the first-run profile: it checks AF home/config/state/log writability, git and git identity, tmux, configured agent commands, daemon health, and remote-hook setup for the current repo when configured.

af doctor is read-only by default: it reports orphaned processes left behind by dead sessions, processes pegging a CPU core inside live sessions, af_ tmux sessions with no backing record, abandoned temp agent-factory homes, af daemons running a binary no install owns, temp directories holding nothing but a daemon socket nobody answers on, directories af's own test harness left under the temp dir when a test run ended before its cleanup, pinned remote host-key directories no session owns, and daemon problems (stale socket, stale pid file, a daemon still running a replaced binary). With --fix it kills orphans whose ancestry markers prove they came from a dead Agent Factory session, removes stale temp homes, stops daemons proven to be running a temp-dir binary, removes dead-socket directories, removes test-residue dirs af's own test harness left under the temp dir when a test run ended before its cleanup, and removes orphaned host-key pins, logging each action; individual observations it cannot verify are reported, never touched, and stay advisory rather than failing the run, but a check that stops before finishing counts as incomplete rather than advisory. Exits 1 when an actionable condition remains or a check stopped before finishing — advisory warnings do not.

The last two are the debris a test or debug run leaves behind (#3845). A daemon whose binary lives under the temp dir is not an install, and one that has been up past a grace window cannot belong to a test run that is still going, so --fix stops it — never the daemon serving this home, and never one whose home could not be read. A binary that is merely missing is reported rather than stopped: af upgrade replaces it in place, so every healthy daemon looks that way until it restarts. A directory holding nothing but a dead daemon-http.sock is removed with a plain rmdir rather than a recursive delete, so one that has gained anything since the scan fails instead of being swept up with it.

When the repository you run it in configures a remote-hook backend, af doctor also validates that setup, so a misconfigured remote surfaces as a diagnosable problem instead of a cryptic failure at session-launch time:

  • remote-config — the required remote_hooks commands (one of provision_cmd/launch_cmd, plus delete_cmd) are present, naming the missing field and the in-repo config file when one is not. It also rejects a config still carrying the removed pre-PR7 keys (list_cmd/attach_cmd/terminal_cmd), naming the stale key and pointing at the migration recipe.
  • remote-hook-script — every configured hook command resolves to something runnable: a path that exists and carries the execute bit (with the exact chmod +x fix otherwise), or a bare name found on $PATH.

There is no connectivity round-trip probe: the provision-and-expose contract has no read-only verb (launch_cmd provisions real infrastructure and starts an af agent-server; delete_cmd tears it down), so the live wire round-trip is exercised by actually creating a session, not by a doctor dry-run.

Those two checks run only for a repo that configures remote_hooks. Run outside a git repo, or in a repo with no remote backend — the common local-only case — they collapse to a single n/a — no remote backend configured line and add no findings, so local users see no new noise. They are validated against the current working directory's repository; run af doctor from inside the repo whose remote setup you want to check.

One check in this section is deliberately not repo-scoped:

  • orphaned-hook-host — a provision_cmd session pins one host key under $AGENT_FACTORY_HOME/hook-hosts/<name> and owns that directory until its delete_cmd succeeds; a few paths (notably a delete_cmd that answers with an error) leave one behind with no owner. Hook names are one namespace for the whole machine, so this check asks the running daemon for every project's sessions and reads every project's records from disk, rather than scoping itself to the current repo. It also runs whether or not this repo configures a remote backend, because an orphan outlives the config that created it. --fix removes a directory only on proof that no session — live, archived, mid-kill, or killed and awaiting its teardown — owns it; if any part of that inventory cannot be read, the directories are reported as undetermined and nothing is removed. It is silent on a machine with no hook-hosts directory.

af reset stops every af daemon running for this AF home (the managed one plus any orphan left behind by an upgrade or a source-built agent-factory --daemon), removes the daemon sockets so a stale daemon or socket cannot serve the next af, kills all Agent Factory tmux sessions, removes every linked git worktree (and its branch) from each repo that has stored sessions — including worktrees you created by hand — wipes the durable project registry (registered bindings and their reachable checkout identity markers for this AF home), and deletes all stored session records. Only daemons owned by you AND using this AGENT_FACTORY_HOME are touched; a daemon or autostart unit for a different AF home is left alone. Use it to recover from a corrupted state, not for day-to-day cleanup — af sessions archive <title> (or a in the TUI) finishes with one session while keeping it restorable.