A command-line coding agent: an agentic loop with tool-calling that reads and writes files and runs shell commands until a task is done, in the directory you are standing in, asking before each change. No agent framework, just each provider's own SDK and a hand-rolled loop.
Benchmarked against Terminal-Bench.
pipx install dietcodeNeeds Python 3.11+. Nothing else to install.
pipx is recommended because it puts dietcode on your PATH and keeps its
dependencies isolated. pip install --user dietcode works too, but on Windows
you may then need to add %APPDATA%\Python\Python311\Scripts to PATH yourself.
dietcode doctor # checks Python, PATH and credentialsThen log in once:
dietcode login # pick a provider, paste a key (input is hidden)
dietcode auth # check what is configuredThe key goes into your OS keychain (Windows Credential Manager, macOS
Keychain, Secret Service on Linux), falling back to a 0600 file at
~/.dietcode/credentials.json. It is never written into the project.
| Provider | Free tier | Get a key |
|---|---|---|
groq |
yes, generous | console.groq.com/keys |
gemini |
yes | aistudio.google.com/apikey |
openai |
no | platform.openai.com |
anthropic |
no | console.anthropic.com |
Each runs on its own SDK, so nothing is squeezed through a compatibility
layer. Any OpenAI-compatible endpoint also works via --base-url (Ollama,
vLLM, OpenRouter).
Now run it from anywhere:
cd ~/some-project
dietcodeIt works in the directory you are standing in, and asks before anything that writes, deletes, or reaches outside it.
Running from a checkout instead
git clone https://github.com/DevPatils/dietCode.git
cd dietCode
pip install -e ".[dev]"
python cli.py # same code as the installed commandA .env with GROQ_API_KEY=... also works when running from a checkout.
Troubleshooting. dietcode doctor diagnoses all of these:
| Symptom | Cause |
|---|---|
dietcode: command not found |
its Scripts/bin dir is not on PATH, use pipx |
no credentials for ... |
run dietcode login |
| every action is denied | stdin is not a terminal, so nothing can answer the prompt |
daily quota used up |
that model's free tier is spent, switch with /model |
The default asks. Read-only commands (ls, cat, git status) run without
prompting; anything that writes, deletes, or reaches outside the directory stops
first with [enter] yes [a] always allow [n] no. Enter approves, because
approving reads is most of what the prompt does. It does not approve destructive
commands or anything outside the working directory, where a reflex keypress
should not be enough.
How much it does before asking is a mode, set with --mode or /mode:
| Mode | |
|---|---|
manual |
ask before every command and every write (default) |
accept-edits |
write files freely, still ask before running commands |
plan |
read and think, change nothing |
auto |
do everything without asking (--yes is the same thing) |
accept-edits covers writes inside the working directory only. plan refuses
without prompting, because a prompt you are not allowed to say yes to is theatre,
and it tells the model it is planning so it describes the change instead of
retrying.
Every file change is snapshotted first, so the looser modes are recoverable.
/undo puts the last one back, /undo all reverts everything this session
touched, and /changes lists them. Shell commands are not tracked: guessing
which paths a command will write is worse than being clear that undo covers file
tools only.
A prompt is a decision, not a boundary. A shell command can always cd .., so
what protects you here is seeing what is about to happen and being able to put it
back, not being unable to leave the folder. Do not point it at code you actively
distrust.
Run with no arguments for an interactive session in the current directory:
dietcodeOne session keeps its conversation and its files. The agent remembers what it did on previous turns and the files it built are still there. Ctrl+C interrupts a turn without quitting.
| Command | |
|---|---|
/help |
the list |
/mode |
how much it may do before asking |
/undo, /changes |
put back a file it changed; see what it touched |
/sessions, /fork |
past conversations here; branch this one |
/model, /provider |
switch either, mid-session |
/cost, /files |
tokens spent, files in the working directory |
/clear |
forget the conversation, keep the files |
/login, /logout, /auth, /doctor |
credentials and setup |
/exit |
quit (also Ctrl+D) |
Or pass a task to run once and exit, which is what scripting and the benchmark use:
dietcode "add a test for the parser and make it pass"Every conversation is written to a JSONL transcript under
~/.dietcode/projects/<project>/, not into your repo, because a transcript
holds every file the agent read and every line of shell output.
dietcode --continue # carry on the last conversation here
dietcode --resume # pick one from a list
dietcode --resume 20260806-1045 # or by id, any unambiguous prefix
dietcode --fork 20260806-1045 # branch it; the original is untouchedOne-shot runs are sessions too, so dietcode "do a thing" then
dietcode --continue works. --no-save writes nothing.
dietcode --verify "python -m pytest -q" "fix the failing parser test"task_complete then does not end the run until that command exits 0. A failure
goes back to the model with its output. Without it, "done" is the model's
opinion.
On your first prompt in a project with no instructions file, dietcode creates
DIETCODE.md, standing instructions prepended to every session. Fill it in and
every run picks it up. It also keeps its own notes in
~/.dietcode/projects/<project>/memory/memory.md, which it may write and you may
edit; your instructions file stays read-only to it, so a run cannot rewrite its
own brief.
| Flag | Meaning |
|---|---|
--mode manual|accept-edits|plan|auto |
how much it does before asking |
--verify CMD |
command that must exit 0 before it may finish |
--continue / --resume [ID] / --fork ID |
session handling |
--no-save / --no-snapshots / --no-notes |
turn off transcripts / undo / notes |
--steps |
show step separators |
--no-stream |
wait for each reply instead of showing it as it is generated |
--subagents |
let the agent delegate self-contained work to sub-agents |
--no-context |
ignore the project's DIETCODE.md / AGENTS.md / CLAUDE.md |
--provider groq|gemini|openai|anthropic |
which API to use (default: your saved login) |
--base-url URL |
any OpenAI-compatible endpoint (Ollama, vLLM, OpenRouter) |
--max-tokens N |
hard spend ceiling per task |
--context-budget N |
trim the oldest turns above this prompt size (default 48000) |
--yes |
approve every action without asking (dangerous) |
--model NAME |
override the provider's default |
--max-iterations N |
default 12 |
--json |
print metrics as JSON |
--quiet |
only print the final result |
Exit code is 0 when the agent called task_complete, 1 otherwise.
python -m pytest # the whole suite
python -m pytest tests/test_loop.py # one file
python -m pytest -k timeout # one testThe loop tests use a scripted fake client (tests/fake_llm.py), so the suite
needs no API key and makes no network calls.
interactive ─┐
one-shot ──┼─> agent_loop ──> execute_tool ──> Executor ──> your files
tb run ──┘ (agent/loop.py) (agent/tools.py) (agent/sandbox.py)
All three entrypoints run the same loop, and differ only in which Executor is
passed to it. Interactive mode also feeds the previous turn's messages back in
as history. Rendering lives in agent/ui.py so the loop stays UI-free and the
benchmark can run it with no console attached.
Replies stream token by token in both human-facing modes. agent_loop(stream=…)
defaults to off, and the benchmark leaves it off deliberately: streaming
means reassembling tool calls from fragments, which is strictly more machinery
to go wrong, and a scored run gains nothing from output nobody watches. Both
transports normalize to the same Completion, so the loop itself is identical
either way.
agent_loop calls the model, executes whatever tools it asks for, feeds the
results back, and repeats until task_complete, a turn with no tool calls, or
max_iterations.
Tools: read_file, write_file, edit_file, find_files, search,
run_shell, task_complete, plus spawn_subagent behind --subagents.
edit_file replaces an exact snippet rather than rewriting the file, so a
one-line change costs one line instead of four hundred. It refuses rather than
guesses: no match, or an ambiguous match, is an error explaining what to fix.
--subagents lets the agent delegate self-contained work to a fresh agent that
shares the files but not the conversation, and reports back only a summary.
The context isolation is the point. Passing the transcript back would cost as
much as doing the work inline.
Project instructions. If the working directory has a DIETCODE.md,
AGENTS.md, CLAUDE.md or .cursorrules, it is appended to the system prompt
and takes precedence over the defaults. Read from the host, so the agent can't
rewrite its own standing orders. --no-context skips it.
The only thing that differs between the CLI and the benchmark is which Executor
gets passed in, so both run identical tool code.
execute_toolnever raises. Llama and Qwen emit malformed tool-call JSON, invented tool names and wrong-typed arguments often enough that treating those as exceptions would kill a run several times per benchmark. Every failure comes back as an error string the model can read and correct.- Tool calls written as prose are recovered. On the very first real run,
llama-3.3-70b emitted
<function/run_shell {...}</function>as message text rather than through the tool-calling API. The loop saw no tool calls and stopped on step 1 with the task untouched.extract_tool_calls_from_textparses the known text formats, and the recovered call is rewritten into the transcript in correct structural form. Counted separately asrecovered_tool_calls, because it measures the model, not the scaffold. - File tools go through the executor, not the host filesystem. Otherwise the benchmark agent would read the host while its shell acts in the container.
task_completebatched with the work gets deferred. Models often emit write + run +task_completein a single turn, declaring the output verified before a single tool result existed. One run wrote bash into a.pyfile, got aSyntaxError, and claimed success in the same breath. It would have scored a false pass. The loop now feeds the results back and requirestask_completeon its own turn.- Schemas stay permissive where the dispatcher coerces. Groq validates tool
arguments server-side and 400s the whole generation on a mismatch; a model
sending
"timeout": "10"killed a run. The rejected text comes back infailed_generation, so the call is salvaged from it rather than lost. - The shell wrapper persists the working directory between calls. Each one
is a fresh process, so
cd srcin one command would otherwise be silently lost by the next, and the agent would be acting blind. - Written file content is base64'd over argv, so nothing the model generates
can be reinterpreted as shell syntax. Costs a ~1MB write ceiling (
ARG_MAX).
The agent runs shell commands an LLM wrote, in your own directory. Three things stand between it and a mess:
It asks. Anything that writes, deletes, or reaches outside the working
directory stops for approval, and the strictest segment of a compound command
decides. git status && rm -rf build is a destructive command, not a read.
It keeps a copy. Every file a tool changes is snapshotted first, so /undo
and /undo all put things back. Copies live in ~/.dietcode, never in your
project.
It records what happened. The full transcript of every session is on disk, so you can go back and read exactly which command did what.
None of that is containment. A shell command can leave the folder, and --yes
or --mode auto turns the asking off entirely. Point it at work you are willing
to review, and read the prompts.
Long sessions trim their own history: above --context-budget tokens the oldest
turns are dropped, always keeping tool calls and their results together (splitting
a pair makes the API reject the whole request). Trimming applies to what is
sent; the full transcript is still recorded.
The harness does not run on Windows. Use WSL or Linux:
curl -LsSf https://astral.sh/uv/install.sh | sh # uv brings its own Python 3.13
uv tool install terminal-bench
wsl bash scripts/benchmark.sh # hello-world
wsl bash scripts/benchmark.sh broken-python # a single task
DATASET=terminal-bench-core==0.1.1 wsl bash scripts/benchmark.sh "" # everythingDocker Desktop's WSL integration means WSL shares the same daemon, so no second install. The agent itself is fine on Windows; only the harness is not.
Four terminal-bench 0.2.18 problems this works around
- Its dataset downloader shells out to Unix
rm -rf .git. Needs Git'susr/binon PATH, or just run it on Linux. terminal-bench-core@headpoints at./tasks, but the repo moved toharbor-framework/terminal-benchand renamed that directoryoriginal-tasks/. Pin==0.1.1(commit91e10457b5).- Windows blocker: container paths are built with
pathlib.Path, so/tmpbecomes\tmpand the run dies inTmuxSession.__init__with404 Could not find the file \tmp, before the agent is ever called. The0.00%this produces is not a score; checktotal_input_tokens: nullinresults.jsonto tell "harness failed" from "agent failed". - It finishes by printing
output_path.absolute(), which callsos.getcwd(). On a OneDrive-backed folder over WSL's drvfs that can throw after a successful run. Passing an absolute--output-pathavoids the call.
tb does not read .env; the adapter loads it itself, and the script exports
the key as well in case the harness's isolated environment lacks python-dotenv.
Use a fixed 15 to 20 task subset while iterating, not the full suite, and not repeatedly. Groq's free tier is ~1,000 requests/day and each task burns one request per loop step.
Per-task metrics.json and transcript.json are written into the harness's
logging directory; they are the input to the failure-mode table below.
Smoke test only so far: one task, which is not a score.
| Task | Result | Steps | Tokens | Notes |
|---|---|---|---|---|
hello-world |
✅ resolved | 3 | 1,806 | 1 tool call recovered from text |
The full subset run is the next step. The table below stays empty until then rather than extrapolating from a single task.
| Resolution rate | Avg steps | Avg tokens | |
|---|---|---|---|
| This agent | n/a | n/a | n/a |
| Terminus (reference) | n/a | n/a | n/a |
Both defensive mechanisms earned their place immediately. From the transcript:
- Step 1's tool call arrived as prose, not through the tool-calling API. The
recovered call is visible in the log as a synthesized id (
call_1_0) with empty content. Withoutextract_tool_calls_from_textthe loop would have stopped at step 1,hello.txtwould never have been written, and the task would have failed. - Step 2 sent
"timeout": "30"as a string. That is exactly the payload that previously drew a 400 and killed a run; the permissive schema absorbed it.
One task on one model is a smoke test, so treat recovered_tool_calls as the
interesting number here, not the pass.
Built and working end to end: tool dispatch, agent loop, permission gate, sessions with resume and fork, snapshots and undo, project memory, CLI, and the Terminal-Bench adapter.
First real run, "write a python script that prints the first 20 primes and run it"
on llama-3.3-70b-versatile, completed in 4 steps / 4658 tokens:
| Step | What happened |
|---|---|
| 1 | Tool call arrived as text; recovered and run. The command itself was malformed (literal \n inside python -c "...") → SyntaxError |
| 2 | Model read the error, switched to write_file, and used a proper structured tool call |
| 3 | Ran the script; correct output |
| 4 | task_complete |
Not yet done:
- Stretch goal:
spawn_subagent, a fresh loop with isolated message history that returns only its final summary to the parent.agent_looptakes anextra_tool_handlershook for exactly this, and the hook is tested; the tool itself is deliberately left until there is a baseline score to compare against.
