Skip to content

harehare/minder

Repository files navigation

minder

A coding-agent harness in Rust.

CI Security audit License: MIT

Website

Multi-provider (Anthropic, OpenAI, Gemini, Ollama) coding-agent harness with policy/observability hooks written in mq's embeddable query language — a standard ReAct-style tool-calling loop where hooks only answer narrow policy questions at five fixed interception points and never drive the loop itself.

Important

This project is under active development and has not been thoroughly tested end to end yet. Providers, tools, and hooks work individually in unit tests, but the full agent loop hasn't seen broad real-world verification — expect rough edges.

Why minder?

  • Policy without a general-purpose scripting language — hooks are written in mq's embeddable query language, which can't write files, hit the network, or spawn processes, so a hook can observe, block, or transform agent behavior but never cause side effects itself.
  • Nothing hidden — every tool call and result streams live to the terminal, split across stdout (the conversation) and stderr (the execution trace). See Live execution display.
  • Multi-provider — Anthropic, OpenAI, Gemini, and local Ollama (including gpt-oss) behind one interface, switched with a single env var.
  • Extensible without forking — project-local skills, subagents, hooks, WASM tool plugins, and optional MCP servers all live under .agent/ in the target repo, not in minder itself.
  • Unattended runsminder loop works through a Markdown checklist turn by turn, with retry/backoff on transient provider errors and a session that survives a restart.

Packages

Crate Description
minder-cli Command-line interface — minder, minder chat, minder loop
minder-core Session/turn loop, tool-calling protocol, and hook port trait
minder-providers Anthropic/OpenAI/Gemini/Ollama client implementations
minder-tools Built-in tools (file, shell, git, web)
minder-tools-wasm WASI plugin loader and sandboxed host runtime for tool plugins
minder-tools-mcp MCP client — spawns configured servers and exposes their tools
minder-hooks mq-based hook engine (.agent/hooks/*.mq)

Contents

Install

cargo install minder-cli
minder "..."

Prebuilt binaries for Linux/macOS/Windows are attached to each GitHub Release.

Build from source, shell completion
git clone https://github.com/harehare/minder.git
cd minder
cargo build --workspace --release
# or install the binary onto your PATH:
cargo install --path crates/minder-cli

Run in place without installing with cargo run -p minder-cli -- "...". The rest of this README uses minder "..." for brevity.

Shell completion

minder completion bash > /etc/bash_completion.d/minder   # or wherever your distro sources completions from
minder completion zsh  >> ~/.zshrc
minder completion fish > ~/.config/fish/completions/minder.fish

Also supports elvish and powershell. Generated by clap_complete straight from the CLI's own argument definitions, so it never drifts out of sync with minder --help.

Quick start

minder takes a single task string as its only argument and runs it to completion.

$ export ANTHROPIC_API_KEY=sk-ant-...
$ cd path/to/some/project
$ minder "list the top-level files and summarize what this project does"
loaded hooks from .agent/            # only printed if .agent/hooks/*.mq exist
→ ls recursive=false
✓ ls: Cargo.toml  README.md  crates/  ...
The project is a Rust workspace with six crates under crates/... (etc.)

Each turn: the model reads the prompt, optionally calls a tool (read_file, bash, grep, ...), minder runs it in the current working directory, and the result feeds back — repeating until the model replies without requesting another tool call. Everything the agent touches (files read/written, commands run) is scoped to the directory minder was launched from.

Every run's transcript is saved under .agent/sessions/ (gitignored automatically), so you can pick a conversation back up later:

Command Does
minder chat Interactive session: type tasks, one process, shared context
minder --continue "..." Resume the most recent session in this project, run one more task
minder --continue Resume the most recent session interactively
minder --resume <id> "..." Resume a specific session by id (or an unambiguous prefix)

A few real tasks to try:

minder "run the tests and summarize any failures"
minder "find all TODO comments under src/ and turn them into a checklist"
minder "check git status and stage+commit the pending changes with a sensible message"

A baseline policy is always active, even with no .agent/hooks/ at all: it blocks bash rm -rf, and reading/searching paths under .env, node_modules, or .git. Everything else stays unrestricted by default — see Hooks to add more policy before pointing minder at real work.

Live execution display, undo, interrupting a turn, @path attachments, REPL commands

Live execution display

Every tool call streams to the terminal as it happens, not just the final answer, split across two streams so piping minder's answer elsewhere stays clean:

  • stdout — the conversation itself: assistant text, including commentary on turns that also call a tool.
  • stderr — the execution trace: ● tool_name(key=value) before each call, then either a diff stat line (+N -N) and a colorized unified diff (for write_file/edit_file, capped at 40 lines) or a / one-line result summary.
$ minder "fix the off-by-one in the pagination helper"

● grep(pattern=page_size)
  ✓ src/pagination.rs:42:    let end = start + page_size;

● edit_file(path=src/pagination.rs)
  ✓ +1 -1
  --- a/src/pagination.rs
  +++ b/src/pagination.rs
  @@ -40,2 +40,2 @@
  - let end = start + page_size;
  + let end = start + page_size - 1;
Fixed the off-by-one: `end` was one past the last valid index.

Colors turn off automatically when stderr isn't a terminal or NO_COLOR is set. Every line here is overridable per-project from .agent/hooks/*.mq — see Customizing the display. A spinner runs while the model is thinking or a tool call is executing (⠋ Thinking (2.3s)), replaced by the normal / line as soon as that step finishes.

Interrupting a turn

Ctrl-C during a turn (not just while typing) stops it instead of killing the whole process: a running bash/git_* call gets a moment to wind down (e.g. bash kills its child process rather than orphaning it) before being force-aborted regardless — a second Ctrl-C forces this immediately. The turn's partial messages are discarded from history; the rest of the conversation stays intact.

Undoing a turn's file changes

Every write_file/edit_file/delete_file call is checkpointed: the first time a turn touches a given file, its prior content (or the fact that it didn't exist yet) is kept in memory before the change lands. /undo restores every file the most recently completed turn touched back to that state, including a delete_filed one. It only reaches back one turn, and deliberately isn't git-based (no stash/commit/worktree) — it won't catch a file a bash command edited or deleted directly, only the three dedicated file tools.

Attaching files with @path

An @path word — a file or directory, relative to the working directory or absolute — attaches that path's contents to the task, whether typed at the chat prompt or inside a one-shot task string:

minder "review @src/pagination.rs for off-by-one bugs"
minder chat
❯ summarize what's in @src/

A file's content is inlined in a fenced code block; a directory gets a shallow (one-level) listing. Only a path that actually exists on disk expands — @here in "thanks @here for the review" is left untouched. In chat, Tab-completes and live-hints like /-commands do.

REPL commands

A line starting with / inside minder chat (or any interactive session) is a REPL command instead of a task:

Command Does
/help Lists these commands
/model Shows the active provider and model
/clear Clears the conversation history (the session file stays, so --continue still works, just with nothing to continue)
/plan <task> Investigates read-only and proposes a plan for <task> before touching anything — see below
/thinking Toggles showing the model's extended-thinking output (Anthropic only, once thinking_budget is configured — see below)
/todo Shows the model's current todo list (from todo_write)
/undo Reverts the write_file/edit_file changes from the most recently completed turn — see above

/plan runs <task> through a throwaway session that shares the real session's provider and hooks but is only given read-only tools (read_file, grep, glob, ls, git_diff, git_log, git_status, web_fetch, web_search), so the model can investigate but never actually change anything:

❯ /plan add retry-with-backoff to the http client
Planning (read-only investigation, no changes will be made)...

1. Add a `max_retries` field to `HttpClient` in src/http.rs...
2. Wrap the request in a loop that retries on 5xx/timeout with exponential backoff...
3. Add a unit test that a 503 followed by a 200 succeeds after one retry...

Proceed with this plan? [y/N] y

Answering y/yes feeds the plan back into the real session (full tools, normal hooks) as the task to implement; anything else discards it.

Scripting and automation

Piped input and structured output turn minder into a general-purpose filter for a script or pipeline — the same role jq/mq play, but with an LLM doing the transformation. Both only apply to a one-shot task (plain, --continue, or --resume with a task); interactive chat/loop read their own input from stdin and always stream live text, so piping into those has no effect.

Piped input

cat error.log | minder "pull out every distinct error message as a bullet list"
curl -s https://example.com/report.json | minder "summarize the top 3 metrics"
git diff | minder "write a commit message for this diff"

Piped stdin is folded into the task as extra input, capped at 200,000 characters (truncated with a note beyond that, so one huge pipe can't blow the context budget).

Structured output (--output json)

$ minder --output json "does this repo's Cargo.toml set edition 2024?"
{"provider":"anthropic","model":"claude-sonnet-5","answer":"Yes, edition = \"2024\".","error":null}

--output json holds back the live text stream and prints exactly one JSON object after the turn completes: {"provider", "model", "answer", "error"} (answer/error mutually exclusive) — useful for calling minder as a subprocess and parsing its result.

Providers

Selected via MINDER_PROVIDER; MINDER_MODEL overrides the default model for whichever provider is active.

MINDER_PROVIDER Required env Default model Notes
anthropic (default) ANTHROPIC_API_KEY claude-sonnet-5
openai OPENAI_API_KEY gpt-5.4-mini
gemini GEMINI_API_KEY gemini-3.5-flash
ollama none llama3.2 needs a local ollama serve; override the endpoint with OLLAMA_BASE_URL
# Anthropic (default)
ANTHROPIC_API_KEY=... minder "run the tests and summarize failures"

# OpenAI
MINDER_PROVIDER=openai OPENAI_API_KEY=... MINDER_MODEL=gpt-5.4 minder "..."

# Gemini
MINDER_PROVIDER=gemini GEMINI_API_KEY=... minder "..."

# Ollama (local, no key needed)
MINDER_PROVIDER=ollama MINDER_MODEL=llama3.2 minder "..."
OLLAMA_BASE_URL=http://localhost:11434 MINDER_PROVIDER=ollama minder "..."

Project defaults (.agent/config.toml)

Set provider/model once per project instead of re-exporting env vars every session:

# .agent/config.toml
provider = "openai"
model = "gpt-5.4"
# ollama_base_url = "http://localhost:11434"  # only read when provider = "ollama"
# thinking_budget = 4000                      # Anthropic only, see below

Every field is optional and the file itself is optional. Precedence is env var (a one-off override)

.agent/config.toml (this project's default) > the built-in default from the table above.

Extended thinking (Anthropic)

thinking_budget (or MINDER_THINKING_BUDGET, same precedence as above) requests Anthropic extended thinking with that many tokens as the budget. Unset by default — no thinking requested, no extra cost or latency. Once set, thinking is shown live (dimmed, above the final answer); toggle it per-session with /thinking without losing the budget setting.

Running with gpt-oss

gpt-oss (OpenAI's open-weight models, gpt-oss-20b/gpt-oss-120b) runs through the existing ollama provider — no minder changes needed, since Ollama handles the gpt-oss-specific translation over its generic /api/chat endpoint.

Setup steps
  1. Install Ollama v0.11.4+ (needed for correct gpt-oss tool-calling support): https://ollama.com/download, or brew install ollama (macOS) / curl -fsSL https://ollama.com/install.sh | sh (Linux).

  2. Start the server (skip if your install already runs it as a background service): ollama serve.

  3. Pull a gpt-oss model — 20b needs ~16GB RAM/VRAM, 120b needs ~65GB+ (multi-GPU/datacenter): ollama pull gpt-oss:20b.

  4. Point minder at it:

    MINDER_PROVIDER=ollama MINDER_MODEL=gpt-oss:20b minder "..."
    # remote/non-default Ollama host:
    OLLAMA_BASE_URL=http://your-ollama-host:11434 MINDER_PROVIDER=ollama MINDER_MODEL=gpt-oss:20b minder "..."

gpt-oss's reasoning effort (low/medium/high) isn't configurable through minder today — it runs at Ollama's default. minder loop works the same way with gpt-oss as with any other provider.

Tools

Always registered:

Tool Does
read_file Reads a file, optionally restricted to a 1-indexed inclusive line range
write_file Creates or overwrites a file, creating parent directories as needed
edit_file Replaces old_string with new_string in a file (must match exactly once unless replace_all)
delete_file Moves a file to .agent/trash/ instead of deleting it outright; refuses directories
bash Runs a shell command and returns combined stdout/stderr (default 120s timeout)
glob Finds files matching a glob pattern, e.g. **/*.rs
grep Searches file contents by regex, honoring .gitignore, up to 200 matches
ls Lists a directory, honoring .gitignore; recursive for a tree view, up to 500 entries
git_diff Shows git diff output, against a ref, staged or unstaged, optionally path-scoped
git_log Shows commit history
git_status Shows git status
git_commit Creates a commit
worktree_add Creates a new git worktree (git worktree add), optionally on a new or existing branch
worktree_list Lists every worktree linked to the repo (git worktree list)
worktree_remove Removes a worktree and its directory (git worktree remove), leaving its branch intact
web_fetch Fetches an http(s) URL as text; rejects non-http(s) schemes and literal loopback/private-network hosts (partial SSRF guard, not a complete one — use a hook for stronger guarantees)
agent Delegates a task to a named subagent — always available via a built-in general-purpose subagent, no project config required; see Subagents
todo_write Replaces the current todo list with a full updated one, so the model can plan and track progress on a multi-step task

Registered only when configured:

Tool Enabled by
web_search TAVILY_API_KEY set — omitted entirely otherwise, so the model never sees a tool it can't use
skill one or more .agent/skills/*/SKILL.md files present — see Skills

Worktree tools let the agent check out a second branch into its own directory without disturbing the current one — e.g. running main's test suite for comparison while mid-edit on a feature branch. minder itself doesn't switch its own working directory between worktrees; point a fresh minder process at the new directory to actually work inside it.

todo_write always replaces the whole list, never a partial patch. It's not offered to subagents or /plan's read-only session. Run /todo any time to see the current list.

Additional tools can be supplied per-project as WASM plugins or from MCP servers (opt-in feature).

Skills

.agent/skills/commit-messages/SKILL.md
---
name: commit-messages
description: Writes commit messages in this repo's conventional-commit style
---
# Commit messages

Use Conventional Commits: `<type>(<scope>): <summary>`, imperative mood...

Each skill is a directory with a SKILL.md: ----delimited frontmatter (name, description) followed by instructions as the body. minder discovers every .agent/skills/*/SKILL.md at startup and registers a single skill tool listing each skill's name/description — cheap to keep in context every turn. The model calls skill with a name to pull that skill's full body into the conversation only when it's actually relevant.

Skill names must be unique, and startup fails if a SKILL.md is missing frontmatter or the name/description fields. See skills/commit-messages/SKILL.md for a runnable example (copy skills/ to .agent/skills/ in a project to try it).

Subagents

.agent/agents/code-reviewer/AGENT.md
---
name: code-reviewer
description: Reviews a diff for correctness bugs, not style
tools: read_file, grep, glob, git_diff, git_log
---
# Code reviewer

Review the diff you're handed for correctness bugs only...

Each subagent is a directory with an AGENT.md: the same frontmatter shape as a skill (name, description), plus an optional tools field (comma-separated tool names) and a body that becomes that subagent's system prompt. minder registers a single agent tool listing every available subagent; the model calls it with {name, task} to delegate a self-contained piece of work. A built-in general-purpose subagent (full tool access, no AGENT.md required) is always available, even with no project config — a project can override it or add more via .agent/agents/<name>/AGENT.md.

Delegating runs a brand-new AgentSession in-process, to completion, with its own history (it starts with no memory of the parent conversation, so task needs to carry enough context) and its own system prompt. It shares the LLM provider/client, the project's hooks, and the live execution display with the parent, rather than rebuilding any of those.

A subagent's tool list is either every tool the parent has (tools omitted) or restricted to the comma-separated names in tools (unknown names are silently dropped, not an error). The agent tool itself is always excluded from a subagent's own tool list, so delegation never recurses more than one level.

If the model requests more than one agent call in the same turn, they run concurrently — each delegation starts with no shared history with the others, so there's nothing for them to race on. Every other tool call still runs strictly sequentially, in the order the model requested it.

Subagent names must be unique, and startup fails on a missing frontmatter field, same as skills. See agents/code-reviewer/AGENT.md for a runnable example (copy agents/ to .agent/agents/ to try it).

Hooks

.agent/hooks/security.mq
def on_tool_call(call):
  if (call["name"] == "bash" && contains(call["arguments"]["command"], "rm -rf")):
    {"action": "block", "reason": "destructive bash command blocked by policy"}
  else:
    {"action": "allow", "value": call};

Every hook returns {"action": "allow", "value": ...} or {"action": "block", "reason": "..."} (the gate-only hook before_compact returns {"action": "allow"} with no value). Hooks are optional — an undefined hook function is a no-op. A buggy on_tool_call fails closed (blocks the action); every other hook fails open (the buggy transform is skipped).

Hook point mq function Fires
before_agent_start before_agent_start(prompt) Once, before the first LLM call
on_context on_context(messages) Before every LLM call
on_tool_call on_tool_call(call) Before a tool executes (fails closed)
on_tool_result on_tool_result(result) Before a tool's result re-enters history
before_compact before_compact(messages) Before history is truncated under context pressure

See hooks/security.mq for a runnable example (copy it to .agent/hooks/ to try it).

The agent module, default policy, overriding results, customizing the display

The agent module

A small set of convenience functions is always loaded before any hook file, bare-callable with no import needed (an agent_ prefix keeps them out of your own functions' way):

Function Returns
agent_content_blocks(messages) Every message's content array, flattened into one array of blocks
agent_tool_calls(messages) All tool_use blocks so far, unwrapped to {id, name, arguments}
agent_tool_results(messages) All tool_result blocks so far, unwrapped to {tool_call_id, content, is_error}
agent_assistant_texts(messages) Every assistant-authored text string, in order
agent_tool_names(messages) Distinct tool names called so far
agent_error_count(messages) Count of tool results with is_error: true
agent_consecutive_errors(results) Trailing streak of is_error: true results (feed it agent_tool_results(messages))
agent_last_n(items, n) The last n items of any array
# .agent/hooks/circuit_breaker.mq -- stop the turn once 3 tool calls in a row have failed.
def on_context(messages):
  if (agent_consecutive_errors(agent_tool_results(messages)) >= 3):
    {"action": "block", "reason": "3 consecutive tool failures -- pausing for a human"}
  else:
    {"action": "allow", "value": messages};

Default policy

default_policy.mq loads right after the agent module and defines a baseline on_tool_call: it blocks bash commands containing rm -rf, and any read_file/grep/glob/ls call whose path/pattern touches .env, node_modules, or .git — active in every project with zero .agent/ setup. Defining your own on_tool_call fully replaces it; call default_on_tool_call(call) from yours to keep the baseline and layer more checks on top:

def on_tool_call(call):
  if (call["name"] == "web_fetch"):
    {"action": "block", "reason": "no network in this project"}
  else:
    default_on_tool_call(call); # still blocks rm -rf, .env, node_modules, .git

The baseline is data, not an if/elif chain — default_denylist() is a plain array of rules ({"match": "contains" | "path_segment", "arg_keys": [...], "needle": "...", "reason": "..."}), matched by default_matchers(). Adding a project-wide blocked command or path is appending a rule to that array. default_on_tool_call_with(call, extra_rules) layers extra_rules on top of the baseline without rewriting on_tool_call from scratch:

# .agent/hooks/security.mq
def on_tool_call(call):
  default_on_tool_call_with(call, [
    {"match": "contains", "arg_keys": ["command"], "needle": "| sh", "reason": "no piping to a shell"},
    {"match": "path_segment", "arg_keys": ["path"], "needle": "secrets", "reason": "secrets/ is excluded by policy"},
  ]);

Overriding a tool's result

on_tool_call can also override: {"action": "override", "value": {"content": "...", "is_error": false, "metadata": null}} supplies the tool's result directly. The real tool never runs, but the result still flows through on_tool_result afterward like any other. Useful for mocking a tool in tests, or short-circuiting it once some condition is met:

def on_tool_call(call):
  if (call["name"] == "web_fetch"):
    {"action": "override", "value": {"content": "(network disabled in this environment)", "is_error": false, "metadata": None}}
  else:
    {"action": "allow", "value": call};

Customizing the display

The live execution display is driven by two more optional hook functions, checked before minder's own built-in formatting. Both fail open: a broken or undefined render function falls back to the built-in look, since a display bug should never be able to affect what the agent actually does.

Function Called with Controls
render_tool_call(call) the upcoming ToolCall how the ● name(...) header line prints
render_tool_result(arg) {"call": ToolCall, "outcome": ToolExecOutcome} how the result/diff line(s) print

Each returns {"action": "default"} (built-in formatting), {"action": "hide"} (print nothing), or {"action": "text", "value": "...", "style": "..."} (print this instead — style is one of green/red/yellow/cyan/dim/bold, or omitted for no styling):

# .agent/hooks/display.mq -- quiet git_status noise, and prefix bash calls with a shell-style `$`
def render_tool_call(call):
  if (call["name"] == "git_status"):
    {"action": "hide"}
  else:
    {"action": "default"};

def render_tool_result(arg):
  if (arg["call"]["name"] == "bash"):
    {"action": "text", "value": "$ " + arg["call"]["arguments"]["command"], "style": "cyan"}
  else:
    {"action": "default"};

Both functions only see the one call/outcome in front of them, not the conversation — a display decision that needs history should reach for the agent module inside on_context/before_compact instead.

Tool plugins (WASM)

Tools can also be provided by sandboxed WASI plugins, discovered from .agent/tools/:

.agent/tools/weather.wasm
.agent/tools/weather.toml

Every .wasm needs a sidecar .toml manifest of the same name declaring its capabilities — a plugin with no manifest fails to load rather than silently running with zero capabilities:

network = false          # grants the one host-mediated fetch primitive (see below)

[[fs]]
host_dir = "./data"      # resolved relative to the working directory
guest_dir = "/data"
read_only = true

[limits]
timeout_secs = 30
max_memory_pages = 256
fuel = 5_000_000

Plugins are plain wasm32-wasip1 modules (no component model) exporting minder_tool_name, minder_tool_description, minder_tool_parameters_schema, minder_tool_execute, plus minder_alloc/minder_dealloc for passing JSON across linear memory — see crates/minder-tools-wasm/tests/fixtures/echo_plugin for a minimal example (regenerate.sh alongside it has the build command). Filesystem access is granted per-plugin via WASI preopens (none by default); network isn't a raw socket — network = true grants a single host_web_fetch import reusing the built-in web_fetch's SSRF guard. Execution is metered with wasmtime fuel, so a runaway plugin traps instead of hanging.

MCP servers (optional)

MCP is a client/server protocol built around subprocesses and long-lived JSON-RPC sessions, which doesn't fit the WASI sandbox above (no arbitrary process execution by design), so it's wired in on the host side instead, behind an opt-in mcp Cargo feature so the rmcp dependency and its subprocess-spawning code aren't part of the binary unless you ask for them:

cargo install --path crates/minder-cli --features mcp

With the feature enabled, minder discovers .agent/mcp.toml, launches each configured server as a child process over the stdio transport, and registers every tool it advertises as mcp__<server>__<tool>:

# .agent/mcp.toml
[[server]]
name = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]

[[server]]
name = "github"
command = "docker"
args = ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
env = { GITHUB_PERSONAL_ACCESS_TOKEN = "..." }

Built without --features mcp, minder ignores .agent/mcp.toml entirely (the mcp tool / minder-tools-mcp crate is compiled out, not just disabled at runtime). A configured server that fails to start, initialize, or list its tools is a hard error at startup, same as a broken wasm plugin or hook file. Remote tool calls go through on_tool_call/on_tool_result hooks exactly like built-in and wasm tool calls.

Autonomous loop mode

minder loop TODO.md
minder loop TODO.md "ship the v2 pagination rewrite"   # optional overall-goal hint

minder loop <file> ["<goal>"] drives the same AgentSession turn after turn against a Markdown checklist, with no user in the loop, and keeps watching the file for new work once it's clear:

  1. Query <file> for GFM checklist lines that are still unchecked, entirely inside mq-lang — embedded the same way as the hooks engine, just pointed at a file on disk:

    read_file(path) | split(., "\n") | filter(., fn(line): is_regex_match(line, "^\\s*[-*+]\\s+\\[ \\]") end)
    

    This matches lines with a regex rather than walking markdown's list/checkbox AST, since mq-lang's only builtin that parses markdown into that AST (collection) works over an entire directory tree — overkill for re-checking one file every few seconds.

  2. If nothing comes back, the file is done: minder logs that it's idle and polls <file> on an interval for new items.

  3. Otherwise the remaining items are folded into a prompt ("pick the first unfinished item, implement it, then check it off") and handed to run_turn.

  4. Repeat — the item just finished no longer shows up as unchecked, so the next prompt derives naturally from the file's current state.

<!-- TODO.md -->
## Backend
- [x] Set up database schema
- [ ] Add user authentication endpoint
- [ ] Write tests for auth endpoint
$ minder loop TODO.md
[loop 1/50] 2 item(s) remaining in TODO.md
→ write_file path=src/auth.rs
...
[loop 2/50] 1 item(s) remaining in TODO.md
→ edit_file path=tests/auth_test.rs
...
[loop] TODO.md has no unchecked items -- polling every 5s for new work (Ctrl-C to stop)

At that point the process keeps running: add a new - [ ] ... line to TODO.md (by hand, or have something else append to it) and the next poll picks it up automatically, no restart needed.

Safety limits keep a stuck agent from spinning forever, all overridable via env vars:

Env var Default Guards against
MINDER_LOOP_MAX_ITERATIONS 50 Runaway spend — a lifetime cap on actual working turns (idle polling doesn't count)
MINDER_LOOP_POLL_INTERVAL_SECS 5 How often to re-check the file while idle
MINDER_LOOP_QUERY read_file(path) | split(., "\n") | filter(., fn(line): is_regex_match(...) end) (see above) Lets you point at a differently-structured file (a custom "done" marker, a different bullet convention, ...)

If the unchecked count doesn't drop for two consecutive working iterations, the loop stops with an error rather than burning turns on a task the model isn't making progress on.

Resilience for unattended runs

Since a loop is meant to run with nobody watching, minder guards against the ways an unattended process actually dies:

  • Transient provider errors don't end the run — rate limits, 5xx responses, and network blips are retried with backoff (honoring the provider's Retry-After when it sends one) before ever reaching your prompt or the checklist logic.
  • The session survives a restartminder loop <file> keys its session by <file>'s canonical path and saves after every turn, so re-running the exact same command after a crash, a Ctrl-C, or a container restart resumes the conversation instead of starting over — at most the in-flight turn is lost.
  • A durable log, even off-terminalMINDER_LOG_FILE=path/to/log also appends every turn/tool-call/tool-result/retry as a plain-text line to that file, useful for a loop launched under nohup, tmux, or systemd, where stderr isn't being watched live.

Development

Requires just. The same recipes run in CI (ci.yml):

just test-all   # fmt --check, clippy -D warnings, doc tests, nextest
just fmt         # cargo fmt --all -- --check
just lint        # cargo clippy --all-targets --all-features --workspace -- -D clippy::all
just test        # cargo nextest run --workspace --all-features
just deps        # cargo machete (unused dependencies)
just audit       # cargo deny check (licenses/bans/sources/advisories)

Each provider's live round-trip test needs real credentials and is #[ignore]d by default:

ANTHROPIC_API_KEY=... cargo test -p minder-providers -- --ignored anthropic
OPENAI_API_KEY=... cargo test -p minder-providers -- --ignored openai
GEMINI_API_KEY=... cargo test -p minder-providers -- --ignored gemini
cargo test -p minder-providers -- --ignored ollama  # needs `ollama serve` running locally

CI and releases

Every push/PR to main runs tests, rustfmt, clippy, and cargo-deny (Linux; the full Linux/macOS/Windows matrix is available via workflow_dispatch). Separate scheduled/PR-triggered workflows cover cargo audit, CodeQL, spell-checking (typos), unused-dependency detection (cargo-machete), and Actions-workflow security linting (zizmor).

Pushing a vX.Y.Z tag builds the minder binary for Linux (gnu/musl, x86_64/aarch64), macOS (aarch64), and Windows (x86_64) and attaches them — with checksums — to a draft GitHub Release (review and publish it manually), and publishes all crates to crates.io under the minder-* prefix (needs a CARGO_REGISTRY_TOKEN secret). See release.yml / cargo-publish.yml.

Support

License

MIT