Unattended coding agent for local OpenAI-compatible models, with MCP discovery, project instructions, skills, and read/write/edit/shell tools. Built to be started once and left running: no request limits, no wall-clock limits, and a single recovery loop that compacts its own context and reconnects forever.
Install the latest repository version in another project:
python -m pip install --upgrade "pm-coder @ git+https://github.com/flamingrickpat/pm-coder"The package installs the pm-coder command and the pm_coder and
pm_bash_machine Python modules, so you can also drive the agent from your
own code -- see Python API and
In-Memory Bash Machine below.
pm-coder --help
pm-coder # interactive mode
pm-coder --auto "Inspect the current project"
pm-coder --mode auto prompt.txtWhen you start pm-coder it prints its effective configuration so you can verify it is set up the way you expect:
pm-coder
cwd: C:\Users\you\project
endpoint: http://127.0.0.1:8080/v1
model: qwen
shell: powershell (C:\...\powershell.exe)
mcp servers: none configured
skills: 9
selected skill: (none)
instructions: 0 file(s)
temperature: 0.7
max tokens: 8192
context window: 96,256
verbose: off
run id: 2026-08-21_10-54-29_C_Users_you_project
session dir: C:\Users\you\.pm\pm-coder\2026-08-21_10-54-29_C_Users_you_project
Everything pm-coder says about itself goes to stderr. Only the turn's
result goes to stdout, so --mode auto output stays parseable even while the
shell tool is echoing commands. Both streams are forced to UTF-8, because
Windows otherwise picks the console codepage and a single em-dash in a model
response is enough to make the JSON invalid for whatever is parsing it. If you
call the Python API instead and redirect stderr, do the same in your own
process.
Four built-in tools, plus whatever the MCP servers contribute:
| Tool | Signature | Notes |
|---|---|---|
read |
read(path, start_line=1, line_length=…, start_column=1, column_length=…) |
Text files come back as a bounded, line-numbered window whose defaults and hard cap scale with the model's context size; 0 everywhere means full read. JPG/PNG files come back as image attachments the model can see. |
write |
write(path, content, start=0, end=0) |
Creates or fully rewrites a file, creating parent directories. A positive inclusive start–end range replaces (or with empty content, deletes) those lines. Always UTF-8, no BOM. |
edit |
edit(path, old_string, new_string, replace_all=False) |
Exact string match, must be unique unless replace_all. |
powershell / bash |
(command, timeout_seconds) |
Everything else: running, searching, verifying. Named after the selected backend. |
Files are addressed by exact content, never by line number. Every serious
agent harness converged on this independently -- aider dropped line numbers
from its diff hunks, Anthropic's editor tool uses old_str/new_str, and
OpenAI's apply_patch anchors on surrounding context -- because a wrong
string fails loudly and can be retried, while a wrong line range succeeds
and deletes the wrong code. That distinction decides whether an unattended
overnight run degrades or corrupts. Line numbers appear only in read output,
to be quoted back verbatim.
edit reports the closest matching lines when old_string does not match, so
a model that missed by one level of indentation can fix it in one step instead
of re-reading the file. A file's existing line endings are preserved, so
editing a CRLF file on Windows does not rewrite every line.
write/edit exist because doing this through the shell forces the model to
escape the same content twice -- once for the tool-call JSON, once for the
shell -- and PowerShell 5.1's Set-Content silently writes the system ANSI
codepage rather than UTF-8.
read is also the way to look at pictures: a JPG or PNG comes back as
BinaryContent, which Pydantic AI renders as a base64 image_url user
message -- the same wire path MCP screenshots already take. Images are
dropped first when compaction needs room, and require a vision-capable model
served with a projector (e.g. llama.cpp --mmproj) to actually be understood;
without one the run fails loudly on the endpoint's 500 instead of retrying
an unanswerable request forever.
Every option has a --flag and most read an environment variable. The CLI
default is listed in ( ). Options marked environment read from the named
variable when the flag is not passed.
| Option | Default | Environment | What it does |
|---|---|---|---|
--mode {interactive,auto} |
interactive |
— | Run a persistent chat session (interactive), or one prompt that runs to completion and exits (auto). |
--auto |
— | — | Shortcut for --mode auto. |
--prompt (positional) |
— | — | Prompt text (auto mode) or a path to a UTF-8 text file. |
--prompt-file PATH |
— | — | Read the auto-mode prompt from this file. |
--run-id RUN_ID |
new session | — | Resume the session directory <log-root>/<RUN_ID>. |
--log-root DIR |
~/.pm/pm-coder |
— | Session log root. |
--cwd DIR |
current dir | — | Working directory the agent acts inside. MCP/skill/instruction discovery walks up from here. |
--base-url URL |
http://127.0.0.1:8080/v1 |
LOCAL_AGENT_BASE_URL, OPENAI_BASE_URL |
OpenAI-compatible endpoint. |
--api-key KEY |
local |
LOCAL_AGENT_API_KEY, OPENAI_API_KEY |
API key for the endpoint. |
--model MODEL |
first /models entry |
LOCAL_AGENT_MODEL, OPENAI_MODEL |
Model id. When omitted, pm-coder waits for the endpoint and takes the first model it advertises. |
--mcp-config PATH |
auto-discover | — | MCP config file. Discovery walks up looking for .mcp.json, mcp.json, mcp_config.json, .pi/mcp.json, .codex/mcp.json. |
--shell {auto,powershell,bash} |
auto |
LOCAL_AGENT_SHELL |
Host shell tool. auto picks PowerShell on Windows, Bash elsewhere. |
--shell-timeout SECS |
240 |
LOCAL_AGENT_SHELL_TIMEOUT |
Seconds allowed for one host-shell tool call. 0 means no timeout. |
--skill NAME_OR_PATH |
none | — | Load exactly one skill and inject its full SKILL.md into the system prompt, replacing the skill index. Accepts the skill's name or a path to its SKILL.md. |
--context-window N |
0 (ask the endpoint) |
LOCAL_AGENT_CONTEXT_WINDOW |
Token budget used to size compaction. 0 reads the endpoint's advertised n_ctx and subtracts a 2048-token safety margin; if the endpoint does not advertise one, 65536 is assumed. |
--temperature T |
0.7 |
LOCAL_AGENT_TEMPERATURE |
Sampling temperature. |
--max-tokens N |
8192 |
LOCAL_AGENT_MAX_TOKENS |
Generated tokens per model response. 0 lets the server decide. |
--enable-thinking / --disable-thinking |
enabled | — | Allow or disable provider-specific long-form reasoning. |
-v / --verbose |
off | — | Print the raw model stream to stderr as it arrives: thinking, text, and tool-call arguments exactly as they come in, plus request markers and usage. Useful for diagnosing a hang before anything is persisted. |
There are deliberately no request limits, wall-clock limits, or output caps. The agent is meant to be started once and left alone.
Every model turn -- interactive or scripted -- goes through one function,
run_turn, which has three failure policies and no exit condition:
- Out of context. The endpoint rejected the request for length, or a response came back truncated with the context above 75% full. The older history is summarized into a checkpoint and the turn resumes from the last tool result, so the model continues instead of restarting. Each compaction that has to happen again immediately halves the verbatim context it preserves; one that follows real progress starts over at full detail.
- Out of response budget. A response was truncated (
finish_reason == "length") while the context still had room -- it simply outgrew--max-tokens. Summarizing cannot fix that, so pm-coder does not: whatever the model produced is kept, and it is told to continue from where it stopped and to split large writes. A response that stopped inside a tool call is dropped instead, since its arguments are incomplete. - Anything else. Connection refused, a restarted server, an HTTP error, a
model that changed underneath you: pm-coder prints
<error>, trying reconnect..., waits 30 seconds, and retries the same turn from the work already captured. Tool calls that already ran are not repeated.
Nothing but Ctrl-C ends the loop. Startup blocks the same way: if the endpoint is not up yet and the model id or context window still has to be discovered, pm-coder waits for it rather than starting against a server that is not there.
Compaction has three strategies that escalate in order: summarize the interior of any single turn larger than 20% of the context window; otherwise keep whole turns at both ends and summarize the whole turns in between; otherwise collapse the largest turn down to its original request plus a checkpoint. Images in tool results are dropped first -- they are the cheapest thing to lose. If the summarizer itself runs out of context, its input is halved with overlap and summarized recursively.
Auto mode writes one JSON object to stdout:
{"response":"...","run_id":"2026-08-21_10-54-29_C_source_project","duration_seconds":12.34,"tokens_used":{"input_tokens":3413,"output_tokens":297,"requests":3,"tool_calls":2}}Sessions live in ~/.pm/pm-coder/<run_id>/:
messages.json-- Pydantic AI's structured model messages, not a summary or a second prompt. Replayed verbatim intomessage_history, so resuming does not re-prompt anything. Pass--run-id <run_id>to continue. It is rewritten mid-turn, after a tool call and at most every 30 seconds, so a turn that runs all night survives a crash: kill the process at hour nine and--run-idpicks up from the last completed tool call.runs.jsonl-- one line per completed turn.session.json-- run metadata.<timestamp>_<n>.compact.json/.pretty.json-- the exact body of every/chat/completionsrequest, captured below Pydantic AI's message and tool conversion. One pair per request, so a long session produces a lot of them.active-stream.jsonl-- the main agent's current streamed response events. It is reset for each main-agent request.precompact_*.stream.jsonl-- an immutable copy ofactive-stream.jsonlmade only when auto-compaction runs. The checkpoint names this file so a resumed agent can inspect incomplete tool-argument deltas with line ranges. Sub-agents do not create stream spools.
With the same model, tokenizer, system instructions, tools, and chat-template settings, a resumed session sends the same logical prompt tokens. The llama.cpp server's private in-memory KV cache cannot be persisted through the OpenAI-compatible API.
Interactive commands: /clear, /info, /paste, /quit.
Point pm-coder at a pm-minecraft character workspace (e.g. C:/Temp/Floppa).
pm-coder auto-discovers that workspace's .mcp.json, connects to the
minecraft MCP server, and lets the model drive the body while reading
screenshots it captures:
pm-coder --mode auto `
--cwd C:/Temp/Floppa `
"Use the minecraft MCP server. Start with minecraft_observe(include_image=true) to actually look at the screenshot of your surroundings. Get a diamond. Use normal survival mechanics only."--cwd C:/Temp/Floppamakes pm-coder discoverC:/Temp/Floppa/.mcp.jsonautomatically (no--mcp-configneeded).minecraft_observewithinclude_image=truereturns image pixels a multimodal model can inspect directly. Those images are the first thing compaction discards when context runs low.- The workspace's
AGENTS.mdand itsdrafts/*.tsteach the model the exact tool shapes and safe exploration habits. - The MCP connection is opened once and held for the whole session. A model endpoint that dies and comes back does not disturb it, so the bot is not kicked from the world every time llama.cpp restarts.
run_auto has the same behavior as auto CLI mode and returns the result as a
plain dictionary. Reuse its run_id for a continuing conversation:
from pm_coder import run_auto
run_id = None
while True:
prompt = input("You> ")
if not prompt:
break
result = run_auto(prompt, run_id=run_id)
run_id = result["run_id"]
print(result["response"])run_auto owns the event loop and returns a dictionary, so callers never touch
asyncio. Use async_run_auto from an existing asyncio application; it returns
a TurnResult.
Both accept the same keywords as the CLI flags -- cwd, base_url, api_key,
model, mcp_config, shell, shell_timeout, temperature, max_tokens,
enable_thinking, skill, verbose, context_window -- plus run_id and
log_root.
To run many turns against one live agent and one MCP connection, use
open_session directly:
import asyncio
from pm_coder import build_settings, open_session, run_turn
async def main():
settings = build_settings(cwd="C:/Temp/Floppa")
async with open_session(settings) as (agent, discovery, store):
while True:
await run_turn(agent, settings, store, "keep mining until you find diamonds")
asyncio.run(main())BashMachine gives LLM agents a shared Bash workspace that exists only in Python memory.
It uses just-bash-py, which pip installs automatically as a dependency of
the pm-coder package.
from pm_bash_machine import BashMachine
vm = BashMachine()
vm.exec("user", "mkdir -p project && cd project").check()
vm.exec("user", "echo alpha > notes.txt").check()
vm.exec("user", "echo beta >> notes.txt").check()
result = vm.exec("user", "grep -n alpha notes.txt")
print(result.stdout)The virtual filesystem stays available for the lifetime of the BashMachine object.
Agents running on a machine get the same read/write/edit
tools with the same signatures as on the real filesystem -- make_file_tools
simply switches its storage backend to the machine.
The current directory and exported environment variables also persist for each user.
The machine has a user user by default.
Add more users with add_user():
vm.add_user("floppa")
vm.add_user("critic")All users see the same virtual filesystem.
Each user has a separate current directory and shell environment.
vm.exec("user", "cd /shared")
vm.exec("floppa", "cd /scratch")
print(vm.exec("user", "pwd").stdout)
print(vm.exec("floppa", "pwd").stdout)Use Access to control file access.
from pm_bash_machine import AccessThe available values are:
Access.N— the user cannot read or modify the file.Access.R— the user can read the file but cannot modify it.Access.RW— the user can read and modify the file.
Files use Access.RW by default.
vm.write_text("/shared/public.md", "hello")
vm.write_text(
"/shared/reference.md",
"read only",
access=Access.R,
)
vm.write_text(
"/shared/private.md",
"secret",
access=Access.N,
)You can also give different access to different users:
vm.write_text(
"/shared/maybe_private.md",
"hello",
access={
"default": Access.RW,
"floppa": Access.N,
"critic": Access.R,
},
)In this example:
usercan read and modify the file.floppacannot read or modify the file.criticcan read the file but cannot modify it.
The access rules are enforced by the virtual filesystem.
Commands such as cat, grep, cp, rm, redirection, and sed use the same access rules.
Write normal text:
vm.write_text(
"/shared/readme.md",
"# Hello\n",
)Write binary data:
with open("image.png", "rb") as file:
image = file.read()
vm.write_binary(
"/shared/image.png",
image,
)Read files from Python:
text = vm.read_text("/shared/readme.md")
image = vm.read_binary("/shared/image.png")These host-side read methods bypass user access rules.
This is useful when the application owns the virtual machine.
Large files do not need to be copied into the virtual filesystem immediately.
Pass an object with a content() method:
class HugeMarkdown:
def content(self) -> str:
return build_large_markdown()
vm.write_text(
"/shared/huge.md",
HugeMarkdown(),
)content() is called only when something reads the file.
The same mechanism works for binary data:
class HugeImage:
def content(self) -> bytes:
return build_large_image()
vm.write_binary(
"/shared/huge.png",
HugeImage(),
)The lazy value is cached after the first read.
If Bash overwrites the file, the machine removes the lazy value and stores the new file normally.
Agents can save Bash scripts inside the virtual filesystem.
vm.write_text(
"/tools/search.sh",
"""
search_all() {
pattern="$1"
root="${2:-.}"
grep -Rni -- "$pattern" "$root"
}
""",
)An agent can use the tool later:
source /tools/search.sh
search_all "qualia" /sharedThis makes the virtual filesystem useful as both storage and an agent toolbox.
Load a real text file:
vm.load_text(
"notes.md",
"/shared/notes.md",
)Load a real binary file:
vm.load_binary(
"image.png",
"/shared/image.png",
)Dump a virtual file to the real filesystem:
vm.dump(
"/shared/notes.md",
"output/notes.md",
)BashMachine is synchronous.
Normal Python threads can share the same machine object:
shared_vm = BashMachine()
agent_a = Agent(bash=shared_vm)
agent_b = Agent(bash=shared_vm)The machine uses one threading.RLock.
Only one shell call modifies the virtual machine at a time.
Other threads block until the current shell call is complete.
BashMachine is not a full Linux virtual machine.
It does not provide:
- real operating-system users
- POSIX UID or GID values
- background processes
- persistent Python processes
- Docker containers
- real network access
- real Linux ACLs
The access system is a small per-file policy for agent workspaces.
Symbolic and hard links are disabled in user shells because path aliases can bypass path-based access rules.
python -m pip install -e ".[dev]"
python -m pytest -v test_bash_machine.py # offline, no endpoint needed
python -m pytest -v test_llm_coder.py # needs a live OpenAI-compatible endpoint