Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bot/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,8 @@ they're given, so revoke and recreate it if one ever leaks.
bot/copilot-login.sh

The token only grants Copilot; revoke it under GitHub Settings →
Applications.
Applications. The secret holds the login in opencode 1's `auth.json`
format, so one stored before the move to opencode 2 still works.
3. Codex is not used: cgwalters uses opencode for OpenAI models, and Codex
has no bot-specific token, only your own ChatGPT login.

Expand Down
21 changes: 14 additions & 7 deletions bot/agent.Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,23 @@ RUN dnf install -y \
dnf clean all

# opencode is pinned to the version the coordinator's opencode backend was
# tested with: its Copilot provider pin and its builder agent's rules rest on
# how opencode merges its config sources. Before each bump, re-run the
# coordinator image's config check, which plants config of each kind and
# sends the TUI a SIGUSR2 (config reload). It arrives with
# jmarrero/atomic-setup#18 (bot/coordinator/check-opencode-config.py); once
# that is merged:
# tested with: its Copilot provider pin, its policies and its builder
# agent's rules rest on how opencode merges its config sources. Before each
# bump, re-run the coordinator image's config check, which plants config of
# each kind, with and without the loop's pre-start clean, and plants more
# into a running server (opencode reloads config files as they change):
# podman run --rm --entrypoint python3 localhost/bot-coordinator \
# /usr/local/share/bot-coordinator/check-opencode-config.py
RUN npm install -g @anthropic-ai/claude-code @openai/codex @github/copilot opencode-ai@1.19.0 && \
RUN npm install -g @anthropic-ai/claude-code @openai/codex @github/copilot @opencode/cli@2.1.1 && \
npm cache clean --force
# opencode 2 no longer reads OPENCODE_AUTH_CONTENT: this runs it with that
# login (see the script).
COPY --chmod=755 opencode-copilot /usr/local/bin/opencode-copilot
# opencode's own update check reads only the global config, which a job or
# session can write; the npm install isn't the agent user's to replace anyway.
# Its database's name, which would otherwise depend on the release channel:
# opencode-copilot and the coordinator's archive scrub look for this one.
ENV OPENCODE_DISABLE_AUTOUPDATE=1 OPENCODE_DB=opencode.db

# The bot's commit identity, for any commit made in a job.
RUN git config --system user.name "Joseph Marrero" && \
Expand Down
7 changes: 5 additions & 2 deletions bot/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ CLAUDE_CODE_OAUTH_TOKEN = "jmarrero-bot-claude-token"

[agents.opencode]
# GitHub Copilot models through opencode (bot/copilot-login.sh stores the
# login as a podman secret). List models with: opencode models github-copilot
command = ["opencode", "run", "--auto", "-m", "github-copilot/gpt-6.1-sol", "{prompt}"]
# login as a podman secret, which opencode-copilot hands to opencode), on a
# private server rather than opencode's shared background one. List models
# with: podman run --rm --secret jmarrero-bot-copilot-auth,type=env,target=OPENCODE_AUTH_CONTENT \
# localhost/bootc-bot-agent opencode-copilot models --standalone
command = ["opencode-copilot", "run", "--standalone", "--auto", "-m", "github-copilot/gpt-6.1-sol", "{prompt}"]
[agents.opencode.secrets]
OPENCODE_AUTH_CONTENT = "jmarrero-bot-copilot-auth"
6 changes: 3 additions & 3 deletions bot/coordinator/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ COPY --chmod=755 coordinator-loop.py /usr/local/bin/bot-coordinator-loop
COPY operator-goals.md operator-notes.md backends.toml backend-opencode.md /usr/local/share/bot-coordinator/
COPY --chmod=755 bot-opencode /usr/local/bin/bot-opencode
COPY opencode-config.py check-opencode-config.py /usr/local/share/bot-coordinator/
# opencode's config, generated, as its system-managed config: root-owned,
# and loaded after every file the session could write (the base image runs
# as agent).
# opencode's config, generated, as its system config: root-owned, and
# loaded after every file the session could write, its policies evaluated
# last (the base image runs as agent).
USER root
RUN mkdir -p /etc/opencode && \
python3 /usr/local/share/bot-coordinator/opencode-config.py /etc/opencode/opencode.json
Expand Down
103 changes: 66 additions & 37 deletions bot/coordinator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,12 @@ notes and restart the coordinator to change that. Runs are logged in
The session runs on one of an ordered list of backends, from
`~/.config/bot-harness/backends.toml` (default:
[`backends.toml`](backends.toml), which documents the format): Claude Code
(`COORDINATOR_MODEL`, `COORDINATOR_EFFORT`), then opencode in its TUI with
`--auto` (`BOT_OPENCODE_MODEL`, the Copilot login). Each has a launch
(`COORDINATOR_MODEL`, `COORDINATOR_EFFORT`), then opencode 2 in its TUI
with `--auto`, on a private server of its own (`--standalone`, not
opencode's shared background service), with `BOT_OPENCODE_MODEL` and
`BOT_OPENCODE_REVIEW_MODEL` (both needed, passed in
`OPENCODE_CONFIG_CONTENT`: the TUI has no `--model`) and the Copilot login
(which `opencode-copilot` hands it). Each has a launch
command, the secret it needs, notes for the skill, and a usage probe:

- Claude: the 7-day percent that `bot-heartbeat statusline` saves in
Expand Down Expand Up @@ -130,8 +134,8 @@ start that fails 3 times in a row holds that backend for an hour and tries
the next (with none left, the loop exits and the container restarts).

Each backend's `clean` paths are emptied before it starts (opencode's
config, cache and data directories; see below), and a failure to do that
is a failed start.
config, cache, state and data directories; see below), and a failure to do
that is a failed start.

A session can also start and not work: a refused model, a dialog, no
quota. So the first thing `COORDINATOR_PROMPT` and every start note
Expand Down Expand Up @@ -165,49 +169,72 @@ off.

opencode's config is [`opencode-config.py`](opencode-config.py)'s output,
which the image installs as `/etc/opencode/opencode.json`: root-owned, and
opencode's system-managed config. It pins opencode to the Copilot
provider, turns off the update prompt, sharing and the `question` tool,
and defines the `second-opinion` and `builder` agents; it applies to
opencode's system config. It turns off updates and sharing, defines the
`second-opinion` and `builder` agents, and holds the policies: only the
Copilot provider, no plugins, no MCP servers and no `question` tool (whose
dialog would take the next typed message as its answer). It applies to
`bot-opencode`'s runs too. To change it, edit the generator and rebuild.

What this protects against is mistakes, not a session that sets out to
change its own config: the container, its credentials and the trust rules
are the boundary, as for Claude, and the same user can edit its home, put
commands first in `PATH` or signal its own processes.

- The provider pin always wins: opencode loads the managed config last,
also when the TUI reloads its config (SIGUSR2).
- The builder rules are merged with those of a session's own config
directories, keeping the order their keys first appeared in, so a file
there that lists the managed rules first and its own after them
outranks them (on opencode 1.19.0, as the check below shows). So before
each opencode start the loop empties `~/.opencode` and opencode's config
and cache directories (config, agents, plugins, cached packages), and
moves its data directory (sessions, logs, and the account state that
could pull in a remote config) to
`~/.local/state/bot-coordinator/archive/opencode/`, where the 5 newest
are kept, within 2 GiB together (the oldest go first; a single entry
bigger than that is still kept, so it isn't a hard bound). The login
(`auth.json`) is moved out before and back after, so no archive holds
it, and is put back even if archiving fails. The
paths follow `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and `XDG_DATA_HOME`. It
then links the rendered `AGENTS.md` afresh. If anything of that fails
(`clean`, `archive`, `keep` and `relink` in `backends.toml`), so does
the start, like any other failed start. Project config is off
(`OPENCODE_DISABLE_PROJECT_CONFIG`).
opencode 2 loads the system config after every file a session can write
(global, project, `.opencode`, agent files, `OPENCODE_CONFIG_CONTENT`), and
evaluates its policies after theirs. So nothing a session writes outranks
it, also as opencode reloads config files when they change:

- Agents' rules are lists, joined in load order, last match wins: the
builder's rules come after any a session adds, however it orders them.
(opencode 1 merged them by key, so a file that listed the managed rules
first and its own after outranked them.)
- Policies can only take things away (a provider, a plugin, a tool), and
the system config's come last. The one thing after them is an OpenCode
Console organization's policies, which needs a Console login: a fresh
database at each start (below) leaves none.
- [`check-opencode-config.py`](check-opencode-config.py), run in the
image after each opencode bump, checks this: planted config of each kind
after the clean, and the provider pin after a SIGUSR2 reload; it reports
whether the reload picks up a planted builder rule.
- That protects each start. A session that plants config itself can still
get it read later: by a SIGUSR2 reload, or as instructions (the global
`AGENTS.md` link's target is re-read on every turn, and an `AGENTS.md`
next to a file it reads is attached to it, as Claude Code does with
`CLAUDE.md`).
image after each opencode bump, checks this: config of each kind planted
(a project and a global `opencode.json`, `~/.opencode`, an agent file, a
global file that repeats the managed rules and adds its own, a plugin, an
MCP server), with project config off and on, with and without the loop's
clean, and planted into a running server, must leave the builder
refusing `git push`, the `question` tool refused, only the Copilot
provider, no plugin or MCP server started, and the login imported.

So for these rules the per-start clean is no longer needed. It stays for
the rest: the login (opencode 2 keeps it in its database, and takes it
only into a new one) and what a session could leave that isn't a rule
(instructions, agents' prompts, commands, cached packages). Before each
opencode start the loop empties `~/.opencode` and opencode's config, cache
and state directories, and moves its data directory (its database, with
the sessions, the login and any account that could pull in a remote
config, and its logs) to `~/.local/state/bot-coordinator/archive/opencode/`,
where the 5 newest are kept, within 2 GiB together (the oldest go first; a
single entry bigger than that is still kept, so it isn't a hard bound). The
archived database's login and account tables are emptied there (or the
database is dropped if that fails), so no archive holds the login. An
opencode 1 `auth.json` found there is moved out first and put back
(opencode 2 doesn't read it: delete it by hand once). The
paths follow `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_STATE_HOME` and
`XDG_DATA_HOME`. It then links the rendered `AGENTS.md` afresh. If anything
of that fails (`clean`, `archive`, `scrub`, `keep` and `relink` in
`backends.toml`), so does the start, like any other failed start. Project
config is off (`OPENCODE_DISABLE_PROJECT_CONFIG`). Then
[`opencode-copilot`](../opencode-copilot) imports the login from
`OPENCODE_AUTH_CONTENT` into the new database (`opencode auth import`) and
starts opencode.

A session that plants config can still get it used until the next start:
opencode reloads config as it changes, so planted instructions, agents'
prompts, formatter commands or a provider's address take effect at once;
the global `AGENTS.md` link's target is re-read on every turn, and an
`AGENTS.md` next to a file it reads is attached to it, as Claude Code does
with `CLAUDE.md`.

Claude Code's `builder` subagent has a hook that refuses commits, pushes
and GitHub or board writes; under opencode, `builder` is an opencode agent
whose bash permissions do that job. opencode matches each command of a
whose shell permissions do that job (and it may not start subagents). opencode matches each command of a
command line (as its shell parser splits it: `a && b`, `a | b`, `$(b)`
are separate commands) against every pattern, `*` matching anything and
`?` one character, and the last match wins. So after the `*` allow there
Expand Down Expand Up @@ -257,7 +284,9 @@ $(printf push)`, `sh -c "$CMD"`), four or more spaces or tabs between the
words, a shell run without options (`bash script.sh`), a script fed to
a shell through a pipe or a heredoc (`echo 'git push' | sh`, `bash
<<EOF`), git and gh aliases (`git -c alias.p=push p`), other interpreters
(`python3 -c`), scripts that run these, or `curl` to the API.
(`python3 -c`), scripts that run these, or `curl` to the API, or to
opencode's own server, whose password the session's processes can read
(to change the session's own rules).
`bot/test_coordinator_loop.py` checks the rules against opencode's matcher,
with the reviewers' lists of what must and mustn't pass.

Expand Down
32 changes: 17 additions & 15 deletions bot/coordinator/backend-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,31 @@ more authority than a Claude session has. Only the tools differ. Read the
skill's Claude Code terms like this:

- **Subagents** ("the Agent tool", "dispatch a worker", "a reviewer
subagent") are your `task` tool. Start workers with `background: true`:
you are told when one finishes, as Claude is when a background agent
does, so don't wait or poll for them. Use the `general` agent. Several
can run at once. To send a reviewer's findings back to a worker, call
`task` again with that worker's `task_id`, which keeps its context
("resuming it" above).
subagent") are your `subagent` tool. Start workers with `background:
true`: you are told when one finishes, as Claude is when a background
agent does, so don't wait or poll for them. Use the `general` agent.
Several can run at once. To send a reviewer's findings back to a worker,
call `subagent` again with that worker's `sessionID`, which keeps its
context ("resuming it" above). Subagents can't start their own here, so
you start the builders and reviewers yourself, and brief workers to
report back rather than dispatch them.
- **The `builder` agent type** is an opencode agent here, `builder`, with
the same job: its permissions refuse commits, pushes and GitHub or board
writes, as Claude Code's builder hook does. It's on your model, not a
cheaper one.
- **The Bash tool's `run_in_background`** doesn't exist here, and bash
commands time out. Run anything long (a devspace start, a test run)
inside a background task, or detached with `nohup CMD > LOG 2>&1 &`,
and read its log when you next wake. Never sleep in a loop.
- **The Bash tool's `run_in_background`** is the `shell` tool's
`background: true` here: you are told when the command ends. Foreground
commands time out, so run anything long (a devspace start, a test run)
that way, or inside a background subagent. Never sleep in a loop.
- **Second opinions.** You are already the Copilot model, so don't use
`bot-opencode` for workers or reviews (a Bash call can't wait for it).
The other model family is the `second-opinion` subagent: every result
gets one review from a `general` task and one from a `second-opinion`
task, briefed like reviewers ("Read
`bot-opencode` for workers or reviews. The other model family is the
`second-opinion` subagent (its model is set for you): every result gets
one review from a `general` subagent and one from a `second-opinion`
subagent, briefed like reviewers ("Read
~/.agents/skills/coordinator/reviewer-preamble.md and follow it").
Weigh both, as the notes on opencode say.
- **Only one model family.** If `second-opinion` fails (its model is
refused or out of quota), have a second `general` task review it with a
refused or out of quota), have a second `general` subagent review it with a
fresh context, write "single-family review" on the PR and in the item's
Why, and don't merge it yourself, even in the harness repositories: it
waits for the operator, or for a second family to review it.
Expand Down
38 changes: 23 additions & 15 deletions bot/coordinator/backends.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
# env: extra variables for its session.
# clean: files and directories emptied before each start, so that nothing a
# session left there carries over; archive: moved aside (the 5 newest
# kept) and made afresh; keep: regular files that survive either; relink:
# links (link = target) created afresh. Paths take ${VAR:-default}. If
# any of that fails, so does the start.
# kept) and made afresh; keep: regular files that survive either; scrub:
# SQLite databases in an archived directory (file name = [tables]) whose
# tables are emptied there; relink: links (link = target) created afresh.
# Paths take ${VAR:-default}. If any of that fails, so does the start.
# notes: Markdown appended to the coordinator skill while it runs, found
# next to this file, else in /usr/local/share/bot-coordinator/.
# probe: where its usage comes from: "file" (bot-heartbeat statusline's
Expand Down Expand Up @@ -57,37 +58,44 @@ probe = { type = "file", path = "~/.cache/bot-heartbeat/rate-limits.json", windo

[[backend]]
name = "opencode"
# The TUI on a private server of its own (not opencode's shared background
# service), with the Copilot login (opencode-copilot); its model comes from
# OPENCODE_CONFIG_CONTENT below, since the TUI has no --model.
command = [
"opencode", "--auto", "--model", "${BOT_OPENCODE_MODEL}",
"opencode-copilot", "--standalone", "--auto",
["--prompt", "${COORDINATOR_PROMPT}"],
]
requires_env = ["OPENCODE_AUTH_CONTENT", "BOT_OPENCODE_MODEL"]
notes = "backend-opencode.md"
# Before each start, config, agents, plugins and cached packages a session
# could have left are removed, and its data directory (its sessions, logs,
# and accounts that could pull in a remote config) is moved aside, to
# Before each start, config, agents, plugins, cached packages and state a
# session could have left are removed, and its data directory (its database
# with the sessions, the logins and accounts that could pull in a remote
# config, and its logs) is moved aside, to
# ~/.local/state/bot-coordinator/archive/opencode/ (the 5 newest are kept),
# but for the login, which OPENCODE_AUTH_CONTENT overrides anyway. The
# with the logins and accounts emptied from the archived database.
# opencode-copilot then imports the login into the new database. The
# rendered AGENTS.md is linked afresh.
clean = [
"~/.opencode",
"${XDG_CONFIG_HOME:-~/.config}/opencode",
"${XDG_CACHE_HOME:-~/.cache}/opencode",
"${XDG_STATE_HOME:-~/.local/state}/opencode",
]
archive = ["${XDG_DATA_HOME:-~/.local/share}/opencode"]
keep = ["${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json"]
scrub = { "opencode.db" = ["credential", "account", "control_account"] }
relink = { "${XDG_CONFIG_HOME:-~/.config}/opencode/AGENTS.md" = "~/.local/share/bot-skills/AGENTS.md" }
leave_at = 95
return_below = 50
probe = { type = "copilot", auth_env = "OPENCODE_AUTH_CONTENT", interval_minutes = 10 }

[backend.env]
# Workers as background task subagents, which wake the session when done.
OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS = "true"
# Its config is the image's /etc/opencode/opencode.json (opencode-config.py):
# only the Copilot provider, no dialogs that would take typed text, the
# second-opinion and builder agents (see README.md, "The builder under
# opencode"). opencode loads that system-managed config after everything
# else, so files the session can write don't override it; no project config
# at all.
# only the Copilot provider, no plugins or MCP servers, no dialogs that would
# take typed text, the second-opinion and builder agents (see README.md, "The
# builder under opencode"). opencode loads that system config after
# everything else and evaluates its policies last, so files the session can
# write don't override it; no project config at all.
OPENCODE_DISABLE_PROJECT_CONFIG = "true"
# The models: the session's, and the second-opinion reviewer's.
OPENCODE_CONFIG_CONTENT = '{"model": "${BOT_OPENCODE_MODEL}", "agents": {"second-opinion": {"model": "${BOT_OPENCODE_REVIEW_MODEL}"}}}'
Loading