Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

code-monkey

Self-hosted daemon that runs a coding agent as a bot GitHub account. No repo list to maintain: work happens wherever the owner (you, one trusted human) assigns the bot an issue or @mentions it in an issue you authored — your own repos or any public repo.

One Python file, one dependency (PyGithub), polling — no webhooks, no server, no queue. State (processed events, last poll time, retry queue) lives in a JSON file next to the config.

The full flow

1. Trigger. Every poll (default 60 s) the daemon looks for open issues assigned to the bot (any repo it can access) and open issues/PRs where you @mentioned it (GitHub search, all public repos). Only issues you authored count; everyone else is invisible.

2. Clone & branch. The repo is cloned to a temp dir and a branch (code-monkey/issue-N) is created — or an existing branch is checked out on follow-ups.

3. Direct push or fork? Decided per job:

  • Bot has write access (you invited it as collaborator) → the branch lives on the repo itself, pushed directly.

  • No write access (e.g. a public repo the bot isn't a member of) → the bot forks the repo to its own account, pushes the branch to the fork, and opens a cross-repo PR (bot:code-monkey/issue-Nupstream:main) — the standard open-source contribution flow. The fork is created automatically on first use and reused afterwards; you never manage it. Requires a classic PAT (see Requirements).

    If a direct push unexpectedly fails with a 403 (access revoked mid-job, stale permission check), the bot falls back to the same fork flow on the spot before giving up and salvaging.

4. Agent runs. The bot comments on the issue that it has picked up the task, then Claude Code headless (or any runner you configure) works in the checkout: splits the task into subtasks, commits each separately, runs tests. It never pushes; the orchestrator does.

5. Push & PR. Commits are pushed, a PR (Closes #N) is opened — or an existing PR updated — and the bot comments on the issue @mentioning you. If the agent needed more information instead, it makes no commits and its question is posted as the comment.

6. Iterate. The conversation continues where it lives: your comments on the issue or PR, PR reviews, and inline review comments each re-prompt the agent with the whole thread as context, on the same branch. The bot talks only to you and ignores its own comments (different account, different login).

Failure safety, in order:

  • agent hangs → killed at job_timeout (with all child processes), committed subtasks still pushed
  • usage/rate limit (5h session, weekly) → partial work pushed, job auto-resumes when the limit window resets (max 5 attempts)
  • monthly spend limit → partial work pushed, no auto-retry (nothing resets until you raise the limit at claude.ai); reply on the thread to resume
  • push fails (no access, network) → commits preserved locally as a git bundle in salvage/; the next job for that issue restores them and continues — no agent work is ever lost
  • GitHub outage → cycle logged and skipped; last_poll only advances on success, so the next good poll sweeps the gap

Requirements

  • uv (deps are inline PEP 723 metadata; uv run handles them) — or plain Python 3.9+ with pip install PyGithub
  • A dedicated bot GitHub account with a PAT. Two modes:
    • Collaborator mode (private repos, direct pushes): invite the bot as collaborator (write) — needed to be assignable and to push. A fine-grained PAT works: contents, pull requests, issues (read/write), scoped to "All repositories". Caveat: fine-grained PATs can't accept invites (GitHub only offers read-only invitation permission) — accept manually in the bot account; the daemon logs a reminder.
    • Contribution mode (public repos, no membership): the bot forks the repo, pushes the branch to its fork, and opens a cross-repo PR. This requires a classic PAT (public_repo scope, or repo to cover private collaborator repos too) — fine-grained PATs cannot fork, comment, or open PRs on repositories they weren't explicitly granted.
  • Claude Code installed and authenticated (or any other runner, see below)

Setup

cp config.example.json config.json
# edit config.json
export GH_BOT_TOKEN=github_pat_...   # or put it in config.json as bot_token
Key Meaning
owner your GitHub username — the only human the bot listens and talks to
bot_token the bot account's PAT (or set GH_BOT_TOKEN)
poll_interval seconds between polls (default 60)
allowed_tools substituted for {allowed_tools} in the runner
job_timeout max seconds per job (default 3600). A hung agent is killed (with everything it spawned); subtasks committed before the kill are still pushed and the timeout is reported in the comment
retry_delay seconds before auto-resuming a job that hit a usage/rate limit (default 18600 = 5h10m, past the 5h session window), used when the limit error doesn't carry a reset time. Partial work is pushed, the job re-queues itself (max 5 attempts), and the resume picks up from the pushed commits. Monthly spend limit is never retried — raise it and reply to resume
context_limit how many of the most recent thread comments (and inline review comments) go into the agent's prompt (default 50)
salvage_dir where unpushable work is preserved as git bundles (default salvage/ next to the script)
ack_model optional Ollama model (e.g. llama3.2) used to phrase the "picked this up" ack and success status comments ("opened PR", "pushed commits"). A rewrite that drops the PR URL is discarded; empty/absent, any Ollama failure, or a dropped fact → the plain line posts. Error/limit comments are never rephrased. ollama_url overrides the default http://localhost:11434; ack_timeout the per-call timeout in seconds (default 60 — cold model loads are slow)
runners named runner commands; {prompt} and {allowed_tools} are substituted. Use an absolute path to the binary (e.g. /Users/you/.local/bin/claude) — service environments (launchd, cron) run with a minimal PATH that usually lacks ~/.local/bin. Pick one per thread with a run_agent: <name> line in the issue body or any of your comments — the last such line in the thread wins, so you can switch mid-thread. No line → the first entry. Unknown name → the job fails fast with the configured names in the comment
runner single-command form (same template rules), used when runners is absent
env extra env vars for the runner; empty values are ignored

The bot's login is derived from the token — no need to configure it.

Start it

uv run code_monkey.py            # runs forever, polls every poll_interval
uv run code_monkey.py --once     # single poll cycle (testing / cron)

Then: create an issue and assign the bot or @mention it, and watch the PR appear (see The full flow above). Reply on the issue or review the PR to keep going — every owner comment/review triggers a follow-up job on the same branch.

Only events after the first start are picked up (no backfill), except already-open issues assigned to or mentioning the bot, which are picked up on the first poll.

Run as a service (macOS)

LaunchAgent (runs while logged in)

Place ~/Library/LaunchAgents/info.stamat.code-monkey.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>info.stamat.code-monkey</string>
  <key>ProgramArguments</key><array>
    <string>/opt/homebrew/bin/uv</string>
    <string>run</string>
    <string>/Users/stamat/Sites/localhost/code-monkey/code_monkey.py</string>
  </array>
  <key>EnvironmentVariables</key><dict>
    <key>GH_BOT_TOKEN</key><string>github_pat_...</string>
    <key>PATH</key><string>/Users/stamat/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
  </dict>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>StandardOutPath</key><string>/tmp/code-monkey.log</string>
  <key>StandardErrorPath</key><string>/tmp/code-monkey.log</string>
</dict></plist>
launchctl load ~/Library/LaunchAgents/info.stamat.code-monkey.plist

A LaunchAgent runs only while you are logged in and stops on logout.

LaunchDaemon (runs always, even when logged out)

For 24/7 operation (survives logout / reboot), install as a system LaunchDaemon at /Library/LaunchDaemons/info.stamat.code-monkey.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>info.stamat.code-monkey</string>
  <key>ProgramArguments</key><array>
    <string>/opt/homebrew/bin/uv</string>
    <string>run</string>
    <string>/Users/stamat/Sites/localhost/code-monkey/code_monkey.py</string>
  </array>
  <key>EnvironmentVariables</key><dict>
    <key>GH_BOT_TOKEN</key><string>github_pat_...</string>
    <key>HOME</key><string>/Users/stamat</string>
    <key>PATH</key><string>/Users/stamat/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
  </dict>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>StandardOutPath</key><string>/tmp/code-monkey.log</string>
  <key>StandardErrorPath</key><string>/tmp/code-monkey.log</string>
  <key>UserName</key><string>your_user</string>
</dict></plist>
sudo cp info.stamat.code-monkey.plist /Library/LaunchDaemons/
sudo chown root:wheel /Library/LaunchDaemons/info.stamat.code-monkey.plist
sudo launchctl load /Library/LaunchDaemons/info.stamat.code-monkey.plist

Replace your_user with your macOS username and github_pat_... with the actual token. The extra HOME and PATH variables are required because LaunchDaemons run in a minimal environment.

LaunchDaemon + Claude Code auth: LaunchDaemons run outside your GUI session and cannot read the login Keychain, where Claude Code stores its credentials — jobs fail with Not logged in · Please run /login even though claude works in your terminal. Fix: generate a long-lived token with claude setup-token and pass it to the runner via config env: "CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat…". LaunchAgents don't have this problem (they run in your session), but stop on logout.

Feature LaunchAgent LaunchDaemon
Runs while logged in ✅ Yes ✅ Yes
Runs after logout ❌ No ✅ Yes
Starts on boot ❌ No (after login) ✅ Yes
During sleep ❌ Paused ❌ Paused
Resume after wake ✅ Via KeepAlive ✅ Via KeepAlive

Prevent macOS from sleeping

The daemon pauses during sleep and resumes when the Mac wakes. If you need it to run uninterrupted (e.g. for long-running agent jobs at night), prevent sleep:

Temporarily — keep the Mac awake until Ctrl + C:

caffeinate

Or run the daemon under caffeinate:

caffeinate -s uv run code_monkey.py

Permanently — disable automatic sleep on all power sources:

sudo pmset -a sleep 0
sudo pmset -a displaysleep 0
sudo pmset -a disksleep 0

Re-enable later (e.g. sleep after 30 minutes):

sudo pmset -a sleep 30
sudo pmset -a displaysleep 15

Concurrency

Jobs run serially, one at a time, in poll order — multiple mentions in one cycle just queue up. Same-thread events collapse into a single job. Nothing is dropped while a job runs; the next poll sweeps everything that happened meanwhile. Run exactly one daemon instance — two would share state.json and double-process events.

Possible upgrade: parallel jobs via ThreadPoolExecutor(max_workers=N) around run_and_maybe_retry in cycle() (~10 lines). Costs: N× concurrent token burn (usage limits hit faster), interleaved logs, and state-file writes need a lock. Worth it only when serial queueing measurably delays work you'd actually review in parallel.

Custom models / runners

Two independent knobs:

1. Keep Claude Code, swap the model — Claude Code respects ANTHROPIC_BASE_URL. Point it at a LiteLLM proxy (speaks the Anthropic /v1/messages format, routes to any provider):

"env": {
  "ANTHROPIC_BASE_URL": "http://localhost:4000",
  "ANTHROPIC_AUTH_TOKEN": "sk-litellm-...",
  "ANTHROPIC_MODEL": "openai/gpt-5.2"
}

2. Swap the whole harnessrunner is just a command template run in the checkout; {prompt} is substituted. Anything that takes a task, edits files, and commits works (e.g. mini-swe-agent, aider aider --message "{prompt}" --yes, or your own LiteLLM tool-loop script.)

In fact it doesn't have to be an agent at all — any command works. It runs in a fresh clone of the repo; whatever it commits gets pushed and PR'd, its stdout (or a .code-monkey-report.md it writes) is posted back as a comment. A build script, a linter, sh -c 'ruff check . > .code-monkey-report.md' — if it runs in a checkout, it can be a runner.

3. Keep several, pick per thread — declare named commands under runners and choose one from GitHub with a run_agent: <name> line:

"runners": {
  "claude": ["claude", "-p", "{prompt}", "--output-format", "json", "--permission-mode", "acceptEdits", "--allowed-tools", "{allowed_tools}"],
  "mini":   ["uvx", "--with", "fastapi", "--with", "orjson", "mini-swe-agent", "-t", "{prompt}", "-y", "--exit-immediately", "--cost-limit", "0"]
}

Then in the issue body or a follow-up comment:

Refactor the settings loader.

run_agent: mini

The line must stand alone — nothing else on it (a - run_agent: mini list item also works). The last run_agent: line in the thread wins (issue body, then your comments in order), so run_agent: claude in a later comment switches the thread back. Without a line, the first entry in runners is used — order your favorite first.

mini-swe-agent with DeepSeek

mini-swe-agent is a lightweight agent that works great with DeepSeek models — no API proxy needed.

Setup — configure DeepSeek once (sets the global config file):

uvx --with fastapi --with orjson mini-swe-agent --model deepseek/deepseek-chat --yolo \
  --exit-immediately -t "hello, this is a test task"

# or manually:
cat > "$(uvx mini-swe-agent --help 2>&1 | grep -oP "'([^']*\.env)'" | head -1)" <<'ENVEOF'
MSWEA_MODEL_NAME='deepseek/deepseek-chat'
MSWEA_CONFIGURED='true'
ENVEOF

Runner config for config.json:

"runner": ["uvx", "--with", "fastapi", "--with", "orjson", "mini-swe-agent", "-t", "{prompt}", "-y", "--exit-immediately", "--cost-limit", "0"]
Flag Why
--with fastapi workaround for litellm bug (imports proxy code)
--with orjson workaround for litellm bug (imports http parsing utils)
-t "{prompt}" task text (substituted by code-monkey)
-y skip confirmation prompts
--exit-immediately exit when done instead of dropping into interactive mode
--cost-limit 0 unlimited cost (omit or set a dollar cap)

mini-swe-agent runs in the cloned repo directory using its built-in "local" environment — it edits files, runs commands, and commits directly. Because it is piped through uvx, it auto-installs its dependencies on first run with no setup required.

Security

  • Owner authorship is the security boundary. Jobs start only from issues authored by you and assigned to / mentioning the bot; follow-ups only from your comments/reviews on the bot's threads. Everyone else is invisible to it. Third-party comments never trigger anything, but they do appear in the thread context the agent reads — treat public-repo threads as untrusted input (prompt injection surface).
  • Jobs run with --permission-mode acceptEdits and a restricted --allowed-tools list (no arbitrary Bash by default). Widen it (e.g. Bash(npm test:*)) deliberately. Never use --dangerously-skip-permissions here.
  • The PAT is kept away from the agent and the public: it is stripped from the checkout's .git/config right after cloning (fetch/push use explicit URLs), and all tokens (PAT + runner env values) are redacted from log lines and issue comments, so a git error echoing an authenticated URL can't leak them.
  • PAT choice is a scope trade-off: fine-grained (collaborator mode only) is tighter; contribution mode needs a classic PAT, whose reach is the bot account's reach — which is still only repos you invited it to plus its own forks. Revoke access by removing the collaborator, or revoke the PAT for a full kill switch.
  • Collaborator invite handling: invites from anyone but the owner are ignored. Owner invites are auto-accepted only when the token can do so (classic PAT); with a fine-grained PAT the accept fails and the daemon logs a reminder to accept manually in the bot account.

Tests

uv run test_code_monkey.py -v

Covers the pure logic (event detection, owner/bot filtering, grouping, prompt building). Network/git paths are thin wrappers over PyGithub and git.

About

Assign an issue to the bot; it codes, opens a PR, and iterates on your feedback.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages